Source Generator Assertions
TUnit provides source generators to simplify creating custom assertions. Instead of manually writing assertion classes and extension methods, you can decorate your methods with attributes and let the generator do the work.
Overviewâ
There are two ways to create assertions with source generators:
[GenerateAssertion]- Decorate your own methods to generate assertions[AssertionFrom<T>]- Generate assertions from existing library methods
Method-Level Generation: [GenerateAssertion]â
The [GenerateAssertion] attribute allows you to turn any method into a full assertion with minimal code.
Basic Exampleâ
using System.ComponentModel;
using TUnit.Assertions.Attributes;
public static partial class IntAssertionExtensions
{
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be positive")]
public static bool IsPositive(this int value)
{
return value > 0;
}
}
// Usage in tests:
await Assert.That(5).IsPositive(); // â
Passes
await Assert.That(-3).IsPositive(); // â Fails: "Expected to be positive but found -3"
Note: The [EditorBrowsable(EditorBrowsableState.Never)] attribute hides the helper method from IntelliSense. Users will only see the generated assertion extension method IsPositive() on Assert.That(...), not the underlying helper method on int values.
What Gets Generatedâ
The generator creates:
- An
Assertion<T>class containing your logic - An extension method on
IAssertionSource<T> - Full support for chaining with
.Andand.Or
// Generated code (simplified):
public sealed class IsPositive_Assertion : Assertion<int>
{
protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<int> metadata)
{
var result = metadata.Value.IsPositive();
return result ? AssertionResult.Passed : AssertionResult.Failed($"found {metadata.Value}");
}
protected override string GetExpectation() => "to be positive";
}
public static IsPositive_Assertion IsPositive(this IAssertionSource<int> source)
{
source.Context.ExpressionBuilder.Append(".IsPositive()");
return new IsPositive_Assertion(source.Context);
}
Custom Expectation Messagesâ
Use the ExpectationMessage property to provide clear, readable error messages:
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be positive")]
public static bool IsPositive(this int value) => value > 0;
// Error message: "Expected to be positive but found -3"
Using Parameters in Messagesâ
You can reference method parameters in your expectation message using {paramName}:
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be between {min} and {max}")]
public static bool IsBetween(this int value, int min, int max)
{
return value >= min && value <= max;
}
// Error message: "Expected to be between 1 and 10 but found 15"
Without ExpectationMessage:
- Default:
"Expected to satisfy IsBetween but found 15"
With ExpectationMessage:
- Clear:
"Expected to be between 1 and 10 but found 15"
Supported Return Typesâ
1. bool - Simple Pass/Failâ
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be even")]
public static bool IsEven(this int value)
{
return value % 2 == 0;
}
// Usage:
await Assert.That(4).IsEven(); // â
Passes
await Assert.That(3).IsEven(); // â Fails: "Expected to be even but found 3"
2. AssertionResult - Custom Messagesâ
When you need more control over error messages, return AssertionResult:
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be prime")]
public static AssertionResult IsPrime(this int value)
{
if (value < 2)
return AssertionResult.Failed($"{value} is less than 2");
for (int i = 2; i <= Math.Sqrt(value); i++)
{
if (value % i == 0)
return AssertionResult.Failed($"{value} is divisible by {i}");
}
return AssertionResult.Passed;
}
// Usage:
await Assert.That(17).IsPrime(); // â
Passes
await Assert.That(15).IsPrime(); // â Fails: "Expected to be prime but 15 is divisible by 3"
3. Task<bool> - Async Operationsâ
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to exist in database")]
public static async Task<bool> ExistsInDatabaseAsync(this int userId, DbContext db)
{
return await db.Users.AnyAsync(u => u.Id == userId);
}
// Usage:
await Assert.That(userId).ExistsInDatabaseAsync(dbContext);
// If fails: "Expected to exist in database but found 123"
4. Task<AssertionResult> - Async with Custom Messagesâ
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to have valid email")]
public static async Task<AssertionResult> HasValidEmailAsync(this int userId, DbContext db)
{
var user = await db.Users.FindAsync(userId);
if (user == null)
return AssertionResult.Failed($"User {userId} not found");
if (!user.Email.Contains("@"))
return AssertionResult.Failed($"Email '{user.Email}' is invalid");
return AssertionResult.Passed;
}
// Usage:
await Assert.That(123).HasValidEmailAsync(dbContext);
// If fails: "Expected to have valid email but User 123 not found"
Methods with Parametersâ
Add parameters to make your assertions flexible:
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be greater than {threshold}")]
public static bool IsGreaterThan(this int value, int threshold)
{
return value > threshold;
}
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be between {min} and {max}")]
public static bool IsBetween(this int value, int min, int max)
{
return value >= min && value <= max;
}
// Usage:
await Assert.That(10).IsGreaterThan(5); // â
Passes
await Assert.That(3).IsGreaterThan(5); // â Fails: "Expected to be greater than 5 but found 3"
await Assert.That(7).IsBetween(1, 10); // â
Passes
await Assert.That(15).IsBetween(1, 10); // â Fails: "Expected to be between 1 and 10 but found 15"
Benefits:
- Use
{paramName}inExpectationMessageto include parameter values - Parameters automatically get
[CallerArgumentExpression]for great error messages - Each parameter becomes part of the extension method signature
- Error messages show actual values with clear context
Class-Level Generation: [AssertionFrom]â
Use [AssertionFrom] to create assertions from existing methods in libraries or your codebase.
Basic Usageâ
using TUnit.Assertions.Attributes;
[AssertionFrom<string>(nameof(string.IsNullOrEmpty), ExpectationMessage = "to be null or empty")]
[AssertionFrom<string>(nameof(string.StartsWith), ExpectationMessage = "to start with {value}")]
[AssertionFrom<string>(nameof(string.EndsWith), ExpectationMessage = "to end with {value}")]
public static partial class StringAssertionExtensions
{
}
// Usage:
await Assert.That(myString).IsNullOrEmpty();
// If fails: "Expected to be null or empty but found 'test'"
await Assert.That("hello").StartsWith("he");
// If fails: "Expected to start with 'he' but found 'hello'"
With Custom Namesâ
[AssertionFrom<string>(nameof(string.Contains), CustomName = "Has", ExpectationMessage = "to have '{value}'")]
public static partial class StringAssertionExtensions
{
}
// Usage:
await Assert.That("hello world").Has("world"); // â
Passes
await Assert.That("hello").Has("world"); // â Fails: "Expected to have 'world' but found 'hello'"
Negation Supportâ
For bool-returning methods, you can generate negated versions:
[AssertionFrom<string>(nameof(string.Contains), CustomName = "DoesNotContain", NegateLogic = true, ExpectationMessage = "to not contain '{value}'")]
public static partial class StringAssertionExtensions
{
}
// Usage:
await Assert.That("hello").DoesNotContain("xyz"); // â
Passes
await Assert.That("hello").DoesNotContain("ell"); // â Fails: "Expected to not contain 'ell' but found 'hello'"
Note: Negation only works with bool-returning methods. AssertionResult methods determine their own pass/fail logic.
Referencing Methods on Different Typesâ
// Reference static methods from another type
[AssertionFrom<string>(typeof(StringHelper), nameof(StringHelper.IsValidEmail), ExpectationMessage = "to be a valid email")]
public static partial class StringAssertionExtensions
{
}
// Where StringHelper is:
public static class StringHelper
{
public static bool IsValidEmail(string value)
{
return value.Contains("@");
}
}
// Usage:
await Assert.That("user@example.com").IsValidEmail(); // â
Passes
await Assert.That("invalid-email").IsValidEmail(); // â Fails: "Expected to be a valid email but found 'invalid-email'"
Method Body Inlining (Advanced)â
For cleaner code and better IntelliSense, you can use method body inlining with file-scoped classes. This eliminates the need for [EditorBrowsable] attributes entirely.
Using InlineMethodBodyâ
Set InlineMethodBody = true to have the generator inline your method body instead of calling it:
using TUnit.Assertions.Attributes;
// File-scoped class - only visible in this file
file static class BoolAssertions
{
[GenerateAssertion(ExpectationMessage = "to be true", InlineMethodBody = true)]
public static bool IsTrue(this bool value) => value == true;
[GenerateAssertion(ExpectationMessage = "to be false", InlineMethodBody = true)]
public static bool IsFalse(this bool value) => value == false;
}
// Usage in tests:
await Assert.That(myBool).IsTrue(); // â
Clean API, no IntelliSense pollution
What Gets Generated with Inliningâ
Instead of calling your method, the generator inlines the expression directly:
// WITHOUT InlineMethodBody (calls the method):
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<bool> metadata)
{
var value = metadata.Value;
var result = value!.IsTrue(); // Method call
return Task.FromResult(result ? AssertionResult.Passed : AssertionResult.Failed($"found {value}"));
}
// WITH InlineMethodBody (inlines the expression):
protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<bool> metadata)
{
var value = metadata.Value;
var result = value == true; // Inlined!
return Task.FromResult(result ? AssertionResult.Passed : AssertionResult.Failed($"found {value}"));
}
Benefits of Inliningâ
â
No [EditorBrowsable] needed - The helper methods are in a file-scoped class
â
Cleaner IntelliSense - Helper methods don't appear anywhere in IntelliSense
â
Type-safe - The generator fully qualifies all type references automatically
â
Works with parameters - Parameters are automatically substituted
Example with Parametersâ
file static class IntAssertions
{
[GenerateAssertion(ExpectationMessage = "to be positive", InlineMethodBody = true)]
public static bool IsPositive(this int value) => value > 0;
[GenerateAssertion(ExpectationMessage = "to be greater than {threshold}", InlineMethodBody = true)]
public static bool IsGreaterThan(this int value, int threshold) => value > threshold;
}
// Generated code inlines with proper parameter substitution:
// var result = value > 0;
// var result = value > _threshold; // Parameter renamed to field
When to Use Inliningâ
Use InlineMethodBody = true when:
- You want cleaner code without
[EditorBrowsable]attributes - You're using file-scoped classes (C# 11+)
- Your assertion logic is simple (expression-bodied or single return statement)
- You want the cleanest possible API surface
Note: Inlining only works with:
- Expression-bodied methods:
=> expression - Simple block methods with a single return statement:
{ return expression; }
Requirements and Best Practicesâ
Method Requirementsâ
For [GenerateAssertion], your method must:
- Be
static - Have at least one parameter (the value to assert)
- Return
bool,AssertionResult,Task<bool>, orTask<AssertionResult>
Hiding Helper Methods from IntelliSenseâ
Important: Always use [EditorBrowsable(EditorBrowsableState.Never)] on your [GenerateAssertion] methods to prevent IntelliSense pollution.
using System.ComponentModel;
using TUnit.Assertions.Attributes;
public static partial class StringAssertionExtensions
{
// â
GOOD: Hidden from IntelliSense
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion]
public static bool IsEmptyString(this string value) => value.Length == 0;
// â BAD: Will appear in IntelliSense when typing on string values
[GenerateAssertion]
public static bool IsEmptyString(this string value) => value.Length == 0;
}
Why? Without [EditorBrowsable], the helper method appears in IntelliSense when users type on the actual type (e.g., myString.). With the attribute, users only see the proper assertion method on Assert.That(myString)., which is cleaner and less confusing.
Recommended Patternsâ
â DO:
- Always use
[EditorBrowsable(EditorBrowsableState.Never)]on[GenerateAssertion]methods - Always use
ExpectationMessageto provide clear error messages - Use
{paramName}in expectation messages to include parameter values - Use extension methods for cleaner syntax
- Return
AssertionResultwhen you need custom error messages - Use async when performing I/O or database operations
- Keep assertion logic simple and focused
- Use descriptive method names
â DON'T:
- Put complex business logic in assertions
- Make assertions with side effects
- Use
AssertionResultwith negation (it won't work as expected) - Forget to make the containing class
partial - Skip the
[EditorBrowsable]attribute (causes IntelliSense clutter)
Chaining and Compositionâ
All generated assertions support chaining:
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be positive")]
public static bool IsPositive(this int value) => value > 0;
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be even")]
public static bool IsEven(this int value) => value % 2 == 0;
// Usage:
await Assert.That(10)
.IsPositive()
.And.IsEven();
// Or:
await Assert.That(number)
.IsEven()
.Or.IsPositive();
Complete Exampleâ
Here's a comprehensive example showing all features:
using System.ComponentModel;
using TUnit.Assertions.Attributes;
using TUnit.Assertions.Core;
public static partial class UserAssertionExtensions
{
// Simple bool
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to have valid ID")]
public static bool HasValidId(this User user)
{
return user.Id > 0;
}
// With parameters
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to have role '{role}'")]
public static bool HasRole(this User user, string role)
{
return user.Roles.Contains(role);
}
// Custom messages with AssertionResult
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to have valid email")]
public static AssertionResult HasValidEmail(this User user)
{
if (string.IsNullOrEmpty(user.Email))
return AssertionResult.Failed("Email is null or empty");
if (!user.Email.Contains("@"))
return AssertionResult.Failed($"Email '{user.Email}' is not valid");
return AssertionResult.Passed;
}
// Async with database
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to exist in database")]
public static async Task<bool> ExistsInDatabaseAsync(this User user, DbContext db)
{
return await db.Users.AnyAsync(u => u.Id == user.Id);
}
}
// Usage in tests:
[Test]
public async Task ValidateUser()
{
var user = new User { Id = 1, Email = "test@example.com", Roles = ["Admin"] };
await Assert.That(user).HasValidId();
await Assert.That(user).HasRole("Admin");
await Assert.That(user).HasValidEmail();
await Assert.That(user).ExistsInDatabaseAsync(dbContext);
// Chaining:
await Assert.That(user)
.HasValidId()
.And.HasValidEmail()
.And.HasRole("Admin");
}
See Alsoâ
- Custom Assertions (Manual) - For when you need full control