Skip to content
Merged
Changes from 1 commit
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
9a57c06
Add source generator for assertion methods and related tests
thomhurst Oct 12, 2025
c230bf6
Refactor assertion method generator to use core assertion classes and…
thomhurst Oct 12, 2025
91647e6
Add custom expectation message support to assertion attributes and ge…
thomhurst Oct 12, 2025
5c55681
Refactor assertions to use source-generated methods
thomhurst Oct 12, 2025
b244e9d
Migrate assertions to source-generated methods and remove legacy code
thomhurst Oct 12, 2025
241ca90
Refactor assertion infrastructure: update CheckAsync methods for asyn…
thomhurst Oct 12, 2025
0e3937d
Implement source-generated assertion extensions for CultureInfo, Enco…
thomhurst Oct 12, 2025
ea841cc
Add source-generated assertion extensions for Assembly and DateTime t…
thomhurst Oct 12, 2025
125ffd6
Implement source-generated assertion extensions for CancellationToken…
thomhurst Oct 12, 2025
456b7b2
Implement source-generated assertion extensions for Assembly and Canc…
thomhurst Oct 12, 2025
5779fba
Add source-generated assertion extensions for Guid, Stream, Task, Thr…
thomhurst Oct 12, 2025
7a17e68
Refactor source-generated assertion extensions for various types to u…
thomhurst Oct 12, 2025
270f6e9
Implement source-generated assertion extensions for CultureInfo, Date…
thomhurst Oct 12, 2025
5241c87
Add source-generated assertion extensions for DateTimeOffset, IPAddre…
thomhurst Oct 12, 2025
3a9f74d
Add source-generated assertion extensions for Array, Index, Range, an…
thomhurst Oct 12, 2025
349c2ad
Add source-generated assertion extensions for DateOnly, DirectoryInfo…
thomhurst Oct 12, 2025
f7488c2
Add source-generated assertion extensions for FileInfo, DirectoryInfo…
thomhurst Oct 12, 2025
06d4aaf
Refactor assertion extension classes to be partial and update project…
thomhurst Oct 12, 2025
f27edee
Add assertion tests for Guid, Lazy, StringBuilder, Task, and TimeSpan
thomhurst Oct 12, 2025
3efb326
Add EditorBrowsable attribute to assertion extension methods for bett…
thomhurst Oct 12, 2025
69507d2
Add EditorBrowsable attribute to source-generated assertion methods a…
thomhurst Oct 12, 2025
cceced9
Add assertion tests for Boolean, Char, DateTime, DateTimeOffset, and …
thomhurst Oct 12, 2025
0fb3319
Add assertion extensions for collection membership checks and update …
thomhurst Oct 12, 2025
6381ecb
Add comprehensive assertion tests for assembly, cancellation token, I…
thomhurst Oct 12, 2025
3e0f0eb
Add comprehensive assertion tests for various types and functionalities
thomhurst Oct 12, 2025
08ef10f
Add missing using directives for TUnit.Assertions.Extensions in vario…
thomhurst Oct 12, 2025
5c4547a
Add assertion generator tests for various types and conditions
thomhurst Oct 12, 2025
6af4911
Refactor assertion generator test classes to remove specific generato…
thomhurst Oct 12, 2025
5a8209c
Rename assertion classes and update method signatures to include type…
thomhurst Oct 12, 2025
4a4adae
Merge branch 'main' into feature/enhance-assertion-source-gen
thomhurst Oct 13, 2025
9621915
Add diagnostic messages to assertion extension generator tests
thomhurst Oct 13, 2025
5987f0a
Refactor assertion generator tests and remove diagnostic source gener…
thomhurst Oct 13, 2025
b11ffd4
Update assertion generator tests to expect zero generated files
thomhurst Oct 13, 2025
5d65398
Enhance assertion messages by adding ExpectationMessage attributes fo…
thomhurst Oct 13, 2025
d10d8f6
Update MethodAssertionGenerator to always generate extension methods …
thomhurst Oct 13, 2025
31b82a6
Refactor assertion namespaces and enhance task assertions
thomhurst Oct 13, 2025
27618ef
Refactor AsyncDelegateAssertion and TaskAssertion to avoid awaiting t…
thomhurst Oct 13, 2025
de89714
Refactor assertion tests to improve clarity and reliability by addres…
thomhurst Oct 13, 2025
8848571
Remove CleanGenerated target from SourceGenerationDebug.props to stre…
thomhurst Oct 13, 2025
3b068f6
Add RunOn attribute for Windows to relevant file and directory tests
thomhurst Oct 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Enhance assertion messages by adding ExpectationMessage attributes fo…
…r clarity
  • Loading branch information
thomhurst committed Oct 13, 2025
commit 5d65398e4627c8fcdd2770b11ef413aa417bc0ab
121 changes: 91 additions & 30 deletions docs/docs/assertions/extensibility/source-generator-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ using TUnit.Assertions.Attributes;
public static partial class IntAssertionExtensions
{
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion]
[GenerateAssertion(ExpectationMessage = "to be positive")]
public static bool IsPositive(this int value)
{
return value > 0;
Expand All @@ -37,7 +37,7 @@ public static partial class IntAssertionExtensions

// Usage in tests:
await Assert.That(5).IsPositive(); // ✅ Passes
await Assert.That(-3).IsPositive(); // ❌ Fails with clear message
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.
Expand All @@ -59,7 +59,7 @@ public sealed class IsPositive_Assertion : Assertion<int>
return result ? AssertionResult.Passed : AssertionResult.Failed($"found {metadata.Value}");
}

protected override string GetExpectation() => "to satisfy IsPositive";
protected override string GetExpectation() => "to be positive";
}

public static IsPositive_Assertion IsPositive(this IAssertionSource<int> source)
Expand All @@ -71,27 +71,65 @@ public static IsPositive_Assertion IsPositive(this IAssertionSource<int> source)

---

## Custom Expectation Messages

Use the `ExpectationMessage` property to provide clear, readable error messages:

```csharp
[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}`:

```csharp
[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

```csharp
[GenerateAssertion]
[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();
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`:

```csharp
[GenerateAssertion]
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be prime")]
public static AssertionResult IsPrime(this int value)
{
if (value < 2)
Expand All @@ -107,27 +145,30 @@ public static AssertionResult IsPrime(this int value)
}

// Usage:
await Assert.That(17).IsPrime();
// If fails: "Expected to satisfy IsPrime but 15 is divisible by 3"
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

```csharp
[GenerateAssertion]
[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

```csharp
[GenerateAssertion]
[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);
Expand All @@ -143,6 +184,7 @@ public static async Task<AssertionResult> HasValidEmailAsync(this int userId, Db

// Usage:
await Assert.That(123).HasValidEmailAsync(dbContext);
// If fails: "Expected to have valid email but User 123 not found"
```

---
Expand All @@ -152,27 +194,33 @@ await Assert.That(123).HasValidEmailAsync(dbContext);
Add parameters to make your assertions flexible:

```csharp
[GenerateAssertion]
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be greater than {threshold}")]
public static bool IsGreaterThan(this int value, int threshold)
{
return value > threshold;
}

[GenerateAssertion]
[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);
await Assert.That(7).IsBetween(1, 10);
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}` in `ExpectationMessage` to 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: `"Expected to satisfy IsGreaterThan(5) but found 3"`
- Error messages show actual values with clear context

---

Expand All @@ -185,42 +233,47 @@ Use `[AssertionFrom]` to create assertions from existing methods in libraries or
```csharp
using TUnit.Assertions.Attributes;

[AssertionFrom<string>("IsNullOrEmpty")]
[AssertionFrom<string>("StartsWith")]
[AssertionFrom<string>("EndsWith")]
[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

```csharp
[AssertionFrom<string>("Contains", CustomName = "Has")]
[AssertionFrom<string>(nameof(string.Contains), CustomName = "Has", ExpectationMessage = "to have '{value}'")]
public static partial class StringAssertionExtensions
{
}

// Usage:
await Assert.That("hello world").Has("world");
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:

```csharp
[AssertionFrom<string>("Contains", CustomName = "DoesNotContain", NegateLogic = true)]
[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");
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.
Expand All @@ -229,7 +282,7 @@ await Assert.That("hello").DoesNotContain("xyz");

```csharp
// Reference static methods from another type
[AssertionFrom<string>(typeof(StringHelper), "IsValidEmail")]
[AssertionFrom<string>(typeof(StringHelper), nameof(StringHelper.IsValidEmail), ExpectationMessage = "to be a valid email")]
public static partial class StringAssertionExtensions
{
}
Expand All @@ -242,6 +295,10 @@ public static class StringHelper
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'"
```

---
Expand Down Expand Up @@ -282,6 +339,8 @@ public static partial class StringAssertionExtensions

✅ **DO:**
- **Always** use `[EditorBrowsable(EditorBrowsableState.Never)]` on `[GenerateAssertion]` methods
- **Always** use `ExpectationMessage` to provide clear error messages
- Use `{paramName}` in expectation messages to include parameter values
- Use extension methods for cleaner syntax
- Return `AssertionResult` when you need custom error messages
- Use async when performing I/O or database operations
Expand All @@ -302,10 +361,12 @@ public static partial class StringAssertionExtensions
All generated assertions support chaining:

```csharp
[GenerateAssertion]
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be positive")]
public static bool IsPositive(this int value) => value > 0;

[GenerateAssertion]
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion(ExpectationMessage = "to be even")]
public static bool IsEven(this int value) => value % 2 == 0;

// Usage:
Expand All @@ -331,7 +392,7 @@ If you're using the old `CreateAssertionAttribute`:
public static partial class StringAssertionExtensions { }

// New:
[AssertionFrom<string>("StartsWith")]
[AssertionFrom<string>(nameof(string.StartsWith), ExpectationMessage = "to start with {value}")]
public static partial class StringAssertionExtensions { }
```

Expand All @@ -352,23 +413,23 @@ public static partial class UserAssertionExtensions
{
// Simple bool
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion]
[GenerateAssertion(ExpectationMessage = "to have valid ID")]
public static bool HasValidId(this User user)
{
return user.Id > 0;
}

// With parameters
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion]
[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]
[GenerateAssertion(ExpectationMessage = "to have valid email")]
public static AssertionResult HasValidEmail(this User user)
{
if (string.IsNullOrEmpty(user.Email))
Expand All @@ -382,7 +443,7 @@ public static partial class UserAssertionExtensions

// Async with database
[EditorBrowsable(EditorBrowsableState.Never)]
[GenerateAssertion]
[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);
Expand Down
Loading