Skip to content

A fluent assertions library that performs full coverage on any enumerable type.

License

Notifications You must be signed in to change notification settings

NetFabric/NetFabric.Assertive

Repository files navigation

GitHub last commit (master) Build (master) Coverage NuGet Version NuGet Downloads

NetFabric.Assertive

This is a assertions library that performs full coverage on most enumerable types and checks edge scenarios that many developers are not aware of.

Syntax

source.Must()
    .BeNotNull()
    .BeEnumerableOf<int>()
    .BeEqualTo(new[] {0, 1, 2, 3, 4});

Enumerables

This framework uses fluent syntax and the combination of the following methods allow the testing of any type of enumerable in a single assertion:

  • BeEnumerableOf<TActualItem>() - asserts that the type TActual, passed in to Must<TActual>(), is an enumerable that returns a stream of items of type TActualItem.

  • BeAsyncEnumerableOf<TActualItem>() - asserts that the type TActual, passed in to Must<TActual>(), is an asynchronous enumerable that returns a stream of items of type TActualItem.

  • BeEqualTo<TExpectedItem>(IEnumerable<TExpectedItem> expected) - asserts that the actual enumerable object contains the same items and in the same order as expected. It tests all the enumeration forms implemented by the type TActual, passed in to Must<TActual>().

Collections can have multiple forms of enumeration. For example; a collection that implements IReadOnlyList<T> can be enumerated using the indexer, using IEnumerable<T>.GetEnumerator(), using IEnumerable.GetEnumerator() and using a public GetEnumerator() that is not an override of any of these interfaces. There's no guarantee that they all are correctly implemented. The Count property can also return the wrong value.

NOTE: This project uses NetFabric.CodeAnalysis to handle any kind of enumerable or async enumerable. Check its README for a detailed description.

Here's an example of a collection with multiple possible enumerations and enumerator implementations:

public readonly struct MyRange : IReadOnlyList<int>, IList<int>
{    
    public MyRange(int count)
    {
        Count = count;
    }
    
    public readonly int Count { get; }
    
    public int this[int index]
    {
    	get
        {
            if (index < 0 || index >= Count)
                ThrowIndexOutOfRangeException();

            return index;

            static void ThrowIndexOutOfRangeException() => throw new IndexOutOfRangeException();
        }
    }
 
    bool ICollection<int>.IsReadOnly => true;

    public bool Contains(int item) => item >= 0 && item < Count;

    public void CopyTo(int[] array, int arrayIndex)
    {
        for (var index = 0; index < Count; index++)
            array[index + arrayIndex] = index;
    }

    void ICollection<int>.Add(int item) => throw new NotSupportedException();
    void ICollection<int>.Clear() => throw new NotSupportedException();
    bool ICollection<int>.Remove(int item) => throw new NotSupportedException();
    
    int IList<int>.this[int index] 
    { 
        get => this[index]; 
        set => throw new NotSupportedException(); 
    }

    public int IndexOf(int item) => item >= 0 && item < Count ? item : -1;

    void IList<int>.Insert(int index, int item) => throw new NotSupportedException();
    void IList<int>.RemoveAt(int index) => throw new NotSupportedException();
    
    public readonly Enumerator GetEnumerator() => new Enumerator(Count);
    readonly IEnumerator<int> IEnumerable<int>.GetEnumerator() => new DisposableEnumerator(Count);
    readonly IEnumerator IEnumerable.GetEnumerator() => new DisposableEnumerator(Count);
    
    public struct Enumerator
    {
        readonly int count;
        int current;
        
        internal Enumerator(int count)
        {
            this.count = count;
            current = -1;
        }
        
        public readonly int Current => current;
        
        public bool MoveNext() => ++current < count;
    }
    
    class DisposableEnumerator : IEnumerator<int>
    {
        readonly int count;
        int current;
        
        internal DisposableEnumerator(int count)
        {
            this.count = count;
            current = -1;
        }
        
        public int Current => current;
        object IEnumerator.Current => current;
        
        public bool MoveNext() => ++current < count;
        
        public void Reset() => current = -1;
        
        public void Dispose() {}
    }
}

This example has two enumerators to: improve performance, allow casting to an enumerable interface and allow the use of extension methods for collections (like LINQ). The public enumerator is a value-type so that calls are not virtual. It doesn't implement IDispose so that, a foreach that calls it, can be inlined.

It implements IReadOnlyCollection<> as the number of items is known. It also implements ICollection<> so that it performs better when used with LINQ and when converted to an array or a List<>.

It implements IReadOnlyList<> so, the indexer can be used. The indexer performs much better that enumerators. It also implements IList<> so that it can be used on methods that still don't take IReadOnlyList<> parameters.

One single call to the BeEnumerableOf<TActualItem>() assertion validates if all implementations return the same sequence of items. Returned by the multiple GetEnumerator() methods, the Count property, the CopyTo method and the multiple indexers. It doesn't test IndexOf() and only partially tests Contains() methods because these would only work for certain sequences.

No enumerable interfaces

A collection does not have to implement interfaces to be enumerable using foreach or await foreach. For example, these are two valid enumerables:

public class Enumerable<T>
{
    public Enumerable<T> GetEnumerator() 
        => this;

    public T Current 
        => default;

    public bool MoveNext() 
        => default;
}
public class AsyncEnumerable<T>
{
    public AsyncEnumerable<T> GetAsyncEnumerator() 
        => this;

    public T Current 
        => default;

    public ValueTask<bool> MoveNextAsync() 
        => default;
}

To be able to handle these, NetFabric.Assertive does not cast or constrain the collections to an enumerable interface. It uses NetFabric.Reflection to validate if its an enumerable.

Enumerables can also have ref struct enumerators. For example:

class Enumerable<T>
{
    readonly Memory<T> source;
    
    public Enumerable(Memory<T> source)
        => this.source = source;
        
    public Enumerator GetEnumerator()
        => new(source);
        
    public ref struct Enumerator // ref struct enumerator
    {
        readonly Span<T> source;
        int index;
        
        internal Enumerator(Memory<T> source)
        {
            this.source = source.Span;
            index = -1;
        }
        
        public T Current
            => source[index];
            
        public bool MoveNext()
            => ++index < source.Length;
    }
}

These cannot be boxed, so reflection cannot be used to enumerate these as it tries to cast them to object. To support these type of enumerables NetFabric.Assertive generates custom code using expression trees and the data collected by NetFabric.Reflection.

NetFabric.Assertive also supports validation of async enumerables. To be able to enumerate these, its required the use of the async and await keywords so that the compiler generates a complex state machine that enumerates it. Expression trees do not yet support these. For this reason, NetFabric.Assertive uses a wrapper that implements IAsyncEnumerable and calls the enumerable using reflection.

await foreach does not support ref struct enumerators so, the use of reflection in this case, is not an issue.

Limitations

foreach and await foreach support the return by reference of items. Unfortunately reflection only supports this feature since netstandard 2.1 and expression trees do not support it at all.

For these reasons, NetFabric.Assertive only support return by reference in the case of async enumerables and starting from netstandard 2.1.

In the case it's not supported, an exception is thrown. To be able to test the other types of enumeration on the enumerable, it's possible to disable this test by setting the optional parameter warnRefReturns to false:

result.Must()
    .BeEnumerableOf<int>()
    .BeEqualTo(expected, warnRefReturns: false)

This disables this particular test but leaves all the other enabled. You should still compare the enumeration using an alternative method.

References

Credits

The following open-source projects are used to build and test this project:

License

This project is licensed under the MIT license. See the LICENSE file for more info.