This time I'd like you to review my unit-test helpers for binary operators. I wrote them because I'm often to lazy to test these operators because writing those tests is so boring.
My little helpers are based on the MSTest.TestFramework
and are built as extensions for Assert.That
.
They start with a class that introduces the Operator()
extension.
public static class AssertExtensions
{
public static IBinaryOperatorAssert Operator(this Assert assert) => default(IBinaryOperatorAssert);
}
This returns the first interface that I use to chain other extensions to finally get to the operator I want to test:
public interface IBinaryOperatorAssert { }
[BinaryOperator("==")]
public interface IBinaryOperatorEqualAssert { }
[BinaryOperator("!=")]
public interface IBinaryOperatorNotEqualAssert { }
Concrete operator interfaces are decorated with an attribute that specifies its symbol for later usage in messages.
public class BinaryOperatorAttribute : Attribute
{
private readonly string _op;
public BinaryOperatorAttribute(string op) => _op = op;
public override string ToString() => _op;
}
public static class BinaryOperatorAssertExtensions
{
public static IBinaryOperatorEqualAssert Equal(this IBinaryOperatorAssert assert) => default(IBinaryOperatorEqualAssert);
public static IBinaryOperatorNotEqualAssert NotEqual(this IBinaryOperatorAssert assert) => default(IBinaryOperatorNotEqualAssert);
}
The final extensions are implemented in this class that for each TLeft
and TRight
type generates, compiles and caches an expression (with some help of
other classes).
public static class BinaryOperatorEqualAssertExtensions
{
public static void IsTrue<TLeft, TRight>(this IBinaryOperatorEqualAssert assert, TLeft left, IEnumerable<TRight> others)
{
BinaryOperator<IBinaryOperatorEqualAssert>.Check(left, others, Assert.IsTrue, Expression.Equal);
}
public static void IsFalse<TLeft, TRight>(this IBinaryOperatorEqualAssert assert, TLeft left, IEnumerable<TRight> others)
{
BinaryOperator<IBinaryOperatorEqualAssert>.Check(left, others, Assert.IsFalse, Expression.Equal);
}
}
As all operators are binary and return either true
or false
we can pass the acutal assert as an Action<bool, string>
to check the condition and generate the message only in one place:
internal static class BinaryOperator<TBinaryOperator>
{
public static void Check<TLeft, TRight>(TLeft left, IEnumerable<TRight> others, Action<bool, string> assert, Func<Expression, Expression, BinaryExpression> createBinaryExpressionFunc)
{
var opName = typeof(TBinaryOperator).GetCustomAttribute<BinaryOperatorAttribute>();
var opFunc = BinaryExpressionCache.GetOperatorFunc<TLeft, TRight, TBinaryOperator>(createBinaryExpressionFunc);
var i = 0;
foreach (var right in others)
{
assert(
opFunc(left, right),
$"Operator {typeof(TLeft).Name.QuoteWith("'")} {opName} {typeof(TRight).Name.QuoteWith("'")} " +
$"failed for {left.Stringify()} {opName} {right.Stringify()} at [{i++}]."
);
}
}
}
Compiled operators are cached inside another helper:
internal static class BinaryExpressionCache
{
private static readonly ConcurrentDictionary<(Type Left, Type Right, Type Operator), object> ExpressionCache =
new ConcurrentDictionary<(Type Left, Type Right, Type Operator), object>();
public static Func<TLeft, TRight, bool> GetOperatorFunc<TLeft, TRight, TOperator>(Func<Expression, Expression, BinaryExpression> createBinaryExpressionFunc)
{
var key =
(
typeof(TLeft),
typeof(TRight),
typeof(TOperator)
);
return (Func<TLeft, TRight, bool>)ExpressionCache.GetOrAdd(
key,
k => BinaryExpressionFactory<TLeft, TRight>.Create(createBinaryExpressionFunc)
);
}
}
The expressions are created with a simple factory to reduce redundancy when creating other operators.
internal static class BinaryExpressionFactory<TLeft, TRight>
{
public static Func<TLeft, TRight, bool> Create(Func<Expression, Expression, BinaryExpression> createBinaryExpressionFunc)
{
var equalExpr =
createBinaryExpressionFunc(
Expression.Parameter(typeof(TLeft), "left"),
Expression.Parameter(typeof(TRight), "right")
);
return Compile(equalExpr);
}
private static Func<TLeft, TRight, bool> Compile(BinaryExpression binaryExpression)
{
return
Expression.Lambda<Func<TLeft, TRight, bool>>(
binaryExpression,
(ParameterExpression)binaryExpression.Left,
(ParameterExpression)binaryExpression.Right
).Compile();
}
}
Example
With my unit-tests I can now pass it the left
and several right
parameters and check their equality in one go. This is an example from one of my tests:
[TestMethod]
public void opEqual_SameValues_True()
{
Assert.That.Operator().Equal().IsTrue(SoftString.Create("foo"), new[]
{
"foo",
"fOo",
"foo ",
" fOob", // this is purposely invalid to check the message
" foo "
});
}
The message it produces is:
Operator 'SoftString' == 'String' failed for 'foo' == ' fOob' at [3].
==
and !=
are the only operators I've implemented so far and I would like to hear your opinion about it before I fully dive into at and implement the other ones too.