Combinations allows all combinations of the given input lists to be executed, and the results all written to a single file.
public static string BuildAddress(int streetNumber, string street, string city)
{
ArgumentException.ThrowIfNullOrWhiteSpace(street);
ArgumentException.ThrowIfNullOrWhiteSpace(city);
ArgumentOutOfRangeException.ThrowIfLessThan(streetNumber, 1);
return $"{streetNumber} {street}, {city}";
}
[Fact]
public Task BuildAddressTest()
{
int[] streetNumbers = [1, 10];
string[] streets = ["Smith St", "Wallace St"];
string[] cities = ["Sydney", "Chicago"];
return Combination()
.Verify(
BuildAddress,
streetNumbers,
streets,
cities);
}
{
1, Smith St , Sydney : 1 Smith St, Sydney,
1, Smith St , Chicago: 1 Smith St, Chicago,
1, Wallace St, Sydney : 1 Wallace St, Sydney,
1, Wallace St, Chicago: 1 Wallace St, Chicago,
10, Smith St , Sydney : 10 Smith St, Sydney,
10, Smith St , Chicago: 10 Smith St, Chicago,
10, Wallace St, Sydney : 10 Wallace St, Sydney,
10, Wallace St, Chicago: 10 Wallace St, Chicago
}
Key value are aligned based on type.
- Numbers (int, double, float etc) are aligned right
- All other types are aligned left
By default exceptions are not captured. So if an exception is thrown by the method being tested, it will bubble up.
Exceptions can be optionally "captured". This approach uses the Exception.Message
as the result of the method being tested.
To enable exception capture use captureExceptions = true
:
[Fact]
public Task BuildAddressExceptionsTest()
{
int[] streetNumbers = [-1, 0, 10];
string[] streets = ["", " ", "Valid St"];
string[] cities = [null!, "Valid City"];
return Combination(captureExceptions: true)
.Verify(
BuildAddress,
streetNumbers,
streets,
cities
);
}
{
-1, , null : ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
-1, , Valid City: ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
-1, , null : ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
-1, , Valid City: ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
-1, Valid St, null : ArgumentNullException: Value cannot be null. (Parameter 'city').,
-1, Valid St, Valid City: ArgumentOutOfRangeException: streetNumber ('-1') must be greater than or equal to '1'. (Parameter 'streetNumber'). Actual value was -1.,
0, , null : ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
0, , Valid City: ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
0, , null : ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
0, , Valid City: ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
0, Valid St, null : ArgumentNullException: Value cannot be null. (Parameter 'city').,
0, Valid St, Valid City: ArgumentOutOfRangeException: streetNumber ('0') must be greater than or equal to '1'. (Parameter 'streetNumber'). Actual value was 0.,
10, , null : ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
10, , Valid City: ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
10, , null : ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
10, , Valid City: ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'street').,
10, Valid St, null : ArgumentNullException: Value cannot be null. (Parameter 'city').,
10, Valid St, Valid City: 10 Valid St, Valid City
}
Exception capture can be enabled globally:
[ModuleInitializer]
public static void Initialize() =>
CombinationSettings.CaptureExceptions();
If exception capture has been enabled globally, it can be disable at the method test level using captureExceptions: false
.
[Fact]
public Task BuildAddressExceptionsDisabledTest()
{
int[] streetNumbers = [1, 10];
string[] streets = ["Smith St", "Wallace St"];
string[] cities = ["Sydney", "Chicago"];
return Combination(captureExceptions: false)
.Verify(
BuildAddress,
streetNumbers,
streets,
cities);
}
Serialization of results is done using CombinationResultsConverter
namespace VerifyTests;
public class CombinationResultsConverter :
WriteOnlyJsonConverter<CombinationResults>
{
public override void Write(VerifyJsonWriter writer, CombinationResults results)
{
writer.WriteStartObject();
var items = results.Items;
if (items.Count == 0)
{
return;
}
var keysLength = items[0].Keys.Count;
var maxKeyLengths = new int[keysLength];
var keyValues = new string[items.Count, keysLength];
for (var itemIndex = 0; itemIndex < items.Count; itemIndex++)
{
var item = items[itemIndex];
for (var keyIndex = 0; keyIndex < keysLength; keyIndex++)
{
var key = item.Keys[keyIndex];
var name = VerifierSettings.GetNameForParameter(key, pathFriendly: false);
keyValues[itemIndex, keyIndex] = name;
var currentKeyLength = maxKeyLengths[keyIndex];
if (name.Length > currentKeyLength)
{
maxKeyLengths[keyIndex] = name.Length;
}
}
}
var keys = new CombinationKey[keysLength];
for (var itemIndex = 0; itemIndex < items.Count; itemIndex++)
{
for (var keyIndex = 0; keyIndex < keysLength; keyIndex++)
{
keys[keyIndex] = new(
Value: keyValues[itemIndex, keyIndex],
MaxLength: maxKeyLengths[keyIndex],
Type: results.KeyTypes?[keyIndex]);
}
var item = items[itemIndex];
var name = BuildPropertyName(keys);
writer.WritePropertyName(name);
WriteValue(writer, item);
}
writer.WriteEndObject();
}
protected virtual string BuildPropertyName(IReadOnlyList<CombinationKey> keys)
{
var builder = new StringBuilder();
foreach (var (value, maxLength, type) in keys)
{
var padding = maxLength - value.Length;
if (type != null &&
type.IsNumeric())
{
builder.Append(' ', padding);
builder.Append(value);
}
else
{
builder.Append(value);
builder.Append(' ', padding);
}
builder.Append(", ");
}
builder.Length -= 2;
return builder.ToString();
}
protected virtual void WriteValue(VerifyJsonWriter writer, CombinationResult result)
{
switch (result.Type)
{
case CombinationResultType.Void:
writer.WriteValue("void");
break;
case CombinationResultType.Value:
if (result.Value == null)
{
writer.WriteNull();
}
else
{
writer.Serialize(result.Value);
}
break;
case CombinationResultType.Exception:
var exception = result.Exception;
var message = exception.Message;
if (exception is ArgumentException)
{
message = FlattenMessage(message);
}
writer.WriteValue($"{exception.GetType().Name}: {message}");
break;
default:
throw new ArgumentOutOfRangeException();
}
}
static string FlattenMessage(string message)
{
var builder = new StringBuilder();
foreach (var line in message.AsSpan().EnumerateLines())
{
var trimmed = line.TrimEnd();
builder.Append(trimmed);
if (!trimmed.EndsWith('.'))
{
builder.Append(". ");
}
}
builder.TrimEnd();
return builder.ToString();
}
}
Combination serialization can be customized using a Converter.
Inherit from CombinationResultsConverter
and override the desired members.
The below sample override BuildPropertyName
to customize the property name. It bypasses the default implementation and hence does not pad columns or use VerifierSettings.GetNameForParameter
for key conversion.
class CustomCombinationConverter :
CombinationResultsConverter
{
protected override string BuildPropertyName(IReadOnlyList<CombinationKey> keys) =>
string.Join(", ", keys.Select(_ => _.Value));
}
Full control of serialization can be achieved by inheriting from WriteOnlyJsonConverter<CombinationResults>
.
Insert the new converter at the top of the converter stack.
static CustomCombinationConverter customConverter = new();
[ModuleInitializer]
public static void Init() =>
VerifierSettings.AddExtraSettings(_ => _.Converters.Insert(0, customConverter));
{
1, Smith St, Sydney: 1 Smith St, Sydney,
1, Smith St, Chicago: 1 Smith St, Chicago,
1, Wallace St, Sydney: 1 Wallace St, Sydney,
1, Wallace St, Chicago: 1 Wallace St, Chicago,
10, Smith St, Sydney: 10 Smith St, Sydney,
10, Smith St, Chicago: 10 Smith St, Chicago,
10, Wallace St, Sydney: 10 Wallace St, Sydney,
10, Wallace St, Chicago: 10 Wallace St, Chicago
}