diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index d86752265b..18b528665f 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,14 +3,14 @@
"isRoot": true,
"tools": {
"microsoft.templateengine.authoring.cli": {
- "version": "10.0.100",
+ "version": "10.0.101",
"commands": [
"dotnet-template-authoring"
],
"rollForward": false
},
"verify.tool": {
- "version": "0.6.0",
+ "version": "0.7.0",
"commands": [
"dotnet-verify"
],
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 2ddfe817a4..e2fd24a4a7 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -34,7 +34,7 @@ If you then still feel the need to ask a question and need clarification, we rec
- Open an [Issue](https://github.com/thomhurst/TUnit/issues/new).
- Provide as much context as you can about what you're running into.
-- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
+- Provide project and platform versions (.NET SDK, TUnit version, OS), depending on what seems relevant.
We will then take care of the issue as soon as possible.
@@ -50,7 +50,7 @@ A good bug report shouldn't leave others needing to chase you up for more inform
- Make sure that you are using the latest version.
- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://tunit.dev/). If you are looking for support, you might want to check [this section](#i-have-a-question)).
-- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/thomhurst/TUnitissues?q=label%3Abug).
+- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/thomhurst/TUnit/issues?q=label%3Abug).
- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
- Collect information about the bug:
- Stack trace (Traceback)
@@ -72,7 +72,7 @@ Once it's filed:
- The project team will label the issue accordingly.
- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
-- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
+- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be implemented.
### Suggesting Enhancements
@@ -103,3 +103,31 @@ The documentation is generated from files within this repository, so you can for
The relevant files are at `docs > docs > tutorials-[basics|extras|assertions]`
If want to provide sample code for complicated or useful different test suite set-ups, that's also very welcome, as this can help other users get started a lot quicker!
+
+### Code Contributions
+
+When contributing code to TUnit, please keep these important requirements in mind:
+
+#### Dual-Mode Implementation
+TUnit supports both source-generated and reflection-based test discovery. **All changes that affect test discovery or execution must work identically in both modes:**
+- Source Generator path: `TUnit.Core.SourceGenerator`
+- Reflection path: `TUnit.Engine`
+
+#### Snapshot Testing
+If your changes affect the source generator output or public APIs:
+1. Run the relevant tests: `dotnet test TUnit.Core.SourceGenerator.Tests` or `dotnet test TUnit.PublicAPI`
+2. Review any `.received.txt` files generated
+3. If the changes are intentional, rename them to `.verified.txt`
+4. Commit the `.verified.txt` files with your changes
+
+#### Performance Considerations
+TUnit is designed to handle millions of tests. When contributing:
+- Minimize allocations in hot paths (test discovery, execution)
+- Avoid LINQ in performance-critical code
+- Cache reflection results
+- Use `ValueTask` for potentially-sync operations
+
+#### AOT Compatibility
+All code must work with Native AOT and IL trimming. Add appropriate `[DynamicallyAccessedMembers]` annotations when using reflection.
+
+For detailed development guidelines, see the [CLAUDE.md](https://github.com/thomhurst/TUnit/blob/main/CLAUDE.md) file in the repository root.
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
deleted file mode 100644
index 9866b562e7..0000000000
--- a/.github/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -1,9 +0,0 @@
-### Please check the following before raising an issue
-
-- If this is a question, have you checked the documentation at https://tunit.dev ?
- - If you can't find anything there, start a discussion instead at https://github.com/thomhurst/TUnit/discussions
-- If your issue is with a specific piece of functionality, please include code that reproduces the problem. This will make fixes much quicker.
-- Ensure you've checked any existing issues so you're not creating a duplicate. If you find an existing issue, feel free to contribute on that thread, and provide any more context if you can.
-- Is your issue with an IDE such as Rider or Visual Studio? I generally can't fix these, so issues should be reported directly to those teams. E.g. Jetbrains or Microsoft.
-- Do you want a new feature or functionality? Start a discussion first so we can decide together.
-- Have you read the [Contributing Guidelines](../tree/main/.github/CONTRIBUTING.md) ?
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000000..8c6896d7b2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,136 @@
+name: Bug Report
+description: Report a bug or unexpected behavior in TUnit
+title: "[Bug]: "
+labels: ["bug", "needs-triage"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to report a bug! Please fill out the information below to help us investigate.
+
+ **Before submitting**, please check:
+ - [ ] I've searched [existing issues](https://github.com/thomhurst/TUnit/issues) to ensure this isn't a duplicate
+ - [ ] I've read the [documentation](https://tunit.dev/) and this isn't expected behavior
+ - [ ] I'm using the latest version of TUnit
+
+ - type: textarea
+ id: description
+ attributes:
+ label: Description
+ description: A clear and concise description of what the bug is.
+ placeholder: Describe the bug...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected Behavior
+ description: What did you expect to happen?
+ placeholder: I expected...
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual Behavior
+ description: What actually happened?
+ placeholder: Instead, what happened was...
+ validations:
+ required: true
+
+ - type: textarea
+ id: reproduction
+ attributes:
+ label: Steps to Reproduce
+ description: |
+ Please provide a minimal code example that reproduces the issue.
+ Include test code and any relevant configuration.
+ placeholder: |
+ 1. Create a test class with...
+ 2. Run the test using...
+ 3. Observe that...
+
+ ```csharp
+ [Test]
+ public async Task MyTest()
+ {
+ // Minimal reproduction code
+ }
+ ```
+ validations:
+ required: true
+
+ - type: input
+ id: tunit-version
+ attributes:
+ label: TUnit Version
+ description: What version of TUnit are you using?
+ placeholder: "e.g., 0.15.0"
+ validations:
+ required: true
+
+ - type: input
+ id: dotnet-version
+ attributes:
+ label: .NET Version
+ description: What .NET version are you targeting?
+ placeholder: "e.g., .NET 8.0, .NET 9.0"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: os
+ attributes:
+ label: Operating System
+ description: What operating system are you using?
+ options:
+ - Windows
+ - macOS
+ - Linux
+ - Other (please specify in additional context)
+ validations:
+ required: true
+
+ - type: dropdown
+ id: ide
+ attributes:
+ label: IDE / Test Runner
+ description: How are you running the tests?
+ options:
+ - dotnet CLI (dotnet test / dotnet run)
+ - Visual Studio
+ - JetBrains Rider
+ - VS Code
+ - Other (please specify in additional context)
+ validations:
+ required: true
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Error Output / Stack Trace
+ description: |
+ If applicable, include the error message or stack trace.
+ This will be automatically formatted as code.
+ render: shell
+
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional Context
+ description: |
+ Add any other context about the problem here.
+ - Are you using AOT/trimming?
+ - Any custom configuration?
+ - Does this happen with specific test data?
+
+ - type: checkboxes
+ id: ide-issue
+ attributes:
+ label: IDE-Specific Issue?
+ description: |
+ If this issue only occurs in a specific IDE (not via `dotnet test`), it may need to be reported to that IDE's team instead.
+ options:
+ - label: I've confirmed this issue occurs when running via `dotnet test` or `dotnet run`, not just in my IDE
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000..ad0b44e7b5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,11 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Questions & Help
+ url: https://github.com/thomhurst/TUnit/discussions/categories/q-a
+ about: Ask questions and get help from the community
+ - name: Ideas & Feature Discussion
+ url: https://github.com/thomhurst/TUnit/discussions/categories/ideas
+ about: Discuss new features before creating a formal request
+ - name: Documentation
+ url: https://tunit.dev/
+ about: Check the official documentation for guides and API reference
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000000..3d07c58d7f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,102 @@
+name: Feature Request
+description: Suggest a new feature or enhancement for TUnit
+title: "[Feature]: "
+labels: ["enhancement", "needs-triage"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for suggesting a feature! We appreciate your input in making TUnit better.
+
+ **Before submitting**, please:
+ - [ ] Search [existing issues](https://github.com/thomhurst/TUnit/issues) and [discussions](https://github.com/thomhurst/TUnit/discussions) to ensure this hasn't been suggested before
+ - [ ] Check the [documentation](https://tunit.dev/) to confirm this feature doesn't already exist
+ - [ ] Consider starting a [discussion](https://github.com/thomhurst/TUnit/discussions/new?category=ideas) first for larger features to get community feedback
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem Statement
+ description: |
+ Is your feature request related to a problem? Please describe.
+ A clear description of what problem this feature would solve.
+ placeholder: "I'm frustrated when... / It would be helpful if... / Currently I have to..."
+ validations:
+ required: true
+
+ - type: textarea
+ id: solution
+ attributes:
+ label: Proposed Solution
+ description: |
+ Describe the solution you'd like.
+ Include example code/syntax if applicable.
+ placeholder: |
+ I would like TUnit to...
+
+ Example usage:
+ ```csharp
+ [Test]
+ [ProposedAttribute("value")]
+ public async Task MyTest()
+ {
+ // How I envision using this feature
+ }
+ ```
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives Considered
+ description: |
+ Have you considered any alternative solutions or workarounds?
+ What do you currently do instead?
+ placeholder: "I've tried... / Currently I work around this by..."
+
+ - type: dropdown
+ id: category
+ attributes:
+ label: Feature Category
+ description: What area of TUnit does this feature relate to?
+ options:
+ - Test Discovery / Attributes
+ - Test Execution / Lifecycle
+ - Assertions
+ - Data-Driven Testing (Arguments, DataSources)
+ - Parallel Execution
+ - Test Output / Reporting
+ - IDE Integration
+ - Performance
+ - Documentation
+ - Other
+ validations:
+ required: true
+
+ - type: dropdown
+ id: priority
+ attributes:
+ label: How important is this feature to you?
+ description: Help us prioritize by indicating the impact on your workflow.
+ options:
+ - Nice to have - would improve my experience
+ - Important - significantly impacts my workflow
+ - Critical - blocking my adoption/usage of TUnit
+
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional Context
+ description: |
+ Add any other context, screenshots, or examples about the feature request.
+ - Links to similar features in other test frameworks
+ - Use cases that would benefit from this feature
+
+ - type: checkboxes
+ id: contribution
+ attributes:
+ label: Contribution
+ description: Would you be interested in contributing this feature?
+ options:
+ - label: I'm willing to submit a pull request for this feature
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 48206b5edf..c315d096ec 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,5 +1,57 @@
-### Please check the following before creating a Pull Request
+## Description
-- If this is a new feature or piece of functionality, have you started a discussion and gotten agreement on it?
-- If it fixes a bug or problem, is there an issue to track it? If not, create one first and link it please so there's clear visibility.
-- Did you write tests to ensure you code works properly?
\ No newline at end of file
+
+
+## Related Issue
+
+
+
+Fixes #
+
+## Type of Change
+
+
+
+- [ ] Bug fix (non-breaking change that fixes an issue)
+- [ ] New feature (non-breaking change that adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to change)
+- [ ] Documentation update
+- [ ] Performance improvement
+- [ ] Refactoring (no functional changes)
+
+## Checklist
+
+### Required
+
+- [ ] I have read the [Contributing Guidelines](https://github.com/thomhurst/TUnit/blob/main/.github/CONTRIBUTING.md)
+- [ ] If this is a new feature, I started a [discussion](https://github.com/thomhurst/TUnit/discussions) first and received agreement
+- [ ] My code follows the project's code style (modern C# syntax, proper naming conventions)
+- [ ] I have written tests that prove my fix is effective or my feature works
+
+### TUnit-Specific Requirements
+
+
+
+- [ ] **Dual-Mode Implementation**: If this change affects test discovery/execution, I have implemented it in BOTH:
+ - [ ] Source Generator path (`TUnit.Core.SourceGenerator`)
+ - [ ] Reflection path (`TUnit.Engine`)
+- [ ] **Snapshot Tests**: If I changed source generator output or public APIs:
+ - [ ] I ran `TUnit.Core.SourceGenerator.Tests` and/or `TUnit.PublicAPI` tests
+ - [ ] I reviewed the `.received.txt` files and accepted them as `.verified.txt`
+ - [ ] I committed the updated `.verified.txt` files
+- [ ] **Performance**: If this change affects hot paths (test discovery, execution, assertions):
+ - [ ] I minimized allocations and avoided LINQ in hot paths
+ - [ ] I cached reflection results where appropriate
+- [ ] **AOT Compatibility**: If this change uses reflection:
+ - [ ] I added appropriate `[DynamicallyAccessedMembers]` annotations
+ - [ ] I verified the change works with `dotnet publish -p:PublishAot=true`
+
+### Testing
+
+- [ ] All existing tests pass (`dotnet test`)
+- [ ] I have added tests that cover my changes
+- [ ] I have tested both source-generated and reflection modes (if applicable)
+
+## Additional Notes
+
+
diff --git a/.github/release.yml b/.github/release.yml
index 7b6ae04ec9..649761babd 100644
--- a/.github/release.yml
+++ b/.github/release.yml
@@ -2,18 +2,46 @@ changelog:
exclude:
labels:
- ignore-for-release
+ - duplicate
+ - invalid
+ - wontfix
categories:
- - title: Breaking Changes 🛠
+ - title: Breaking Changes
labels:
- Semver-Major
- breaking-change
- breaking
- - title: 🏕 Changes
+ - title: New Features
+ labels:
+ - enhancement
+ - feature
+ - title: Bug Fixes
+ labels:
+ - bug
+ - fix
+ - title: Performance Improvements
+ labels:
+ - performance
+ - title: Documentation
+ labels:
+ - documentation
+ - docs
+ - title: Other Changes
labels:
- '*'
exclude:
labels:
- dependencies
- - title: 👒 Dependencies
+ - enhancement
+ - feature
+ - bug
+ - fix
+ - performance
+ - documentation
+ - docs
+ - Semver-Major
+ - breaking-change
+ - breaking
+ - title: Dependencies
labels:
- - dependencies
\ No newline at end of file
+ - dependencies
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
index dc3652b5b1..8452b0f2ff 100644
--- a/.github/workflows/claude-code-review.yml
+++ b/.github/workflows/claude-code-review.yml
@@ -17,62 +17,41 @@ jobs:
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
-
+
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
-
+
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
- uses: anthropics/claude-code-action@beta
+ uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ prompt: |
+ REPO: ${{ github.repository }}
+ PR NUMBER: ${{ github.event.pull_request.number }}
- # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
- # model: "claude-opus-4-20250514"
-
- # Direct prompt for automated review (no @claude mention needed)
- direct_prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
-
- Be constructive and helpful in your feedback.
- # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
- # use_sticky_comment: true
-
- # Optional: Customize review based on file types
- # direct_prompt: |
- # Review this PR focusing on:
- # - For TypeScript files: Type safety and proper interface usage
- # - For API endpoints: Security, input validation, and error handling
- # - For React components: Performance, accessibility, and best practices
- # - For tests: Coverage, edge cases, and test quality
-
- # Optional: Different prompts for different authors
- # direct_prompt: |
- # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
- # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
- # 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
-
- # Optional: Add specific tools for running tests or linting
- # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
-
- # Optional: Skip review for certain conditions
- # if: |
- # !contains(github.event.pull_request.title, '[skip-review]') &&
- # !contains(github.event.pull_request.title, '[WIP]')
+ Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
+
+ Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
+
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+ claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
index 6de0bdfc3e..d300267f18 100644
--- a/.github/workflows/claude.yml
+++ b/.github/workflows/claude.yml
@@ -26,39 +26,25 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
- uses: anthropics/claude-code-action@beta
+ uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
-
- # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
- # model: "claude-opus-4-20250514"
-
- # Optional: Customize the trigger phrase (default: @claude)
- # trigger_phrase: "/claude"
-
- # Optional: Trigger when specific user is assigned to an issue
- # assignee_trigger: "claude-bot"
-
- # Optional: Allow Claude to run specific commands
- allowed_tools: "Bash(pwsh run-all-engine-tests.ps1)"
-
- # Optional: Add custom instructions for Claude to customize its behavior for your project
- # custom_instructions: |
- # Follow our coding standards
- # Ensure all new code has tests
- # Use TypeScript for new files
-
- # Optional: Custom environment variables for Claude
- # claude_env: |
- # NODE_ENV: test
+
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+ # prompt: 'Update the pull request description to include a summary of changes.'
+
+ # Optional: Add claude_args to customize behavior and configuration
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+ # claude_args: '--allowed-tools Bash(gh pr:*)'
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 4dde9743e2..3d2840bc6d 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -55,7 +55,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Setup .NET 9
uses: actions/setup-dotnet@v5
diff --git a/.github/workflows/deploy-pages-test.yml b/.github/workflows/deploy-pages-test.yml
index 261a136ea7..f7dddc272e 100644
--- a/.github/workflows/deploy-pages-test.yml
+++ b/.github/workflows/deploy-pages-test.yml
@@ -13,7 +13,7 @@ jobs:
run:
working-directory: docs
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index e41288cdef..2040177ff8 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -16,7 +16,7 @@ jobs:
run:
working-directory: docs
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
diff --git a/.github/workflows/dotnet-build-different-locale.yml b/.github/workflows/dotnet-build-different-locale.yml
index fcb846d76d..21309e9d00 100644
--- a/.github/workflows/dotnet-build-different-locale.yml
+++ b/.github/workflows/dotnet-build-different-locale.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 60785d933d..1550c66730 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -26,7 +26,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -54,7 +54,7 @@ jobs:
dotnet-version: 10.0.x
- name: Cache NuGet packages
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
~/.nuget/packages
@@ -66,10 +66,10 @@ jobs:
- name: Docker Setup Docker
if: matrix.os == 'ubuntu-latest'
- uses: docker/setup-docker-action@v4.5.0
+ uses: docker/setup-docker-action@v4.6.0
- name: Cache Playwright Browsers
- uses: actions/cache@v4
+ uses: actions/cache@v5
id: playwright-cache
with:
path: |
@@ -106,21 +106,21 @@ jobs:
publish-packages: ${{ (github.event.inputs.publish-packages || false) && matrix.os == 'ubuntu-latest' }}
- name: Upload Diagnostic Logs
- uses: actions/upload-artifact@v5.0.0
+ uses: actions/upload-artifact@v6.0.0
if: always()
with:
name: TestingPlatformDiagnosticLogs${{matrix.os}}
path: '**/log_*.diag'
- name: Upload Hang Dumps
- uses: actions/upload-artifact@v5.0.0
+ uses: actions/upload-artifact@v6.0.0
if: always()
with:
name: HangDump${{matrix.os}}
path: '**/hangdump*'
- name: NuGet Packages Artifacts
- uses: actions/upload-artifact@v5.0.0
+ uses: actions/upload-artifact@v6.0.0
if: always()
with:
name: 'NuGetPackages-${{matrix.os}}'
diff --git a/.github/workflows/generate-readme.yml b/.github/workflows/generate-readme.yml
index 9b06bcf623..f7a36d093f 100644
--- a/.github/workflows/generate-readme.yml
+++ b/.github/workflows/generate-readme.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
diff --git a/.github/workflows/speed-comparison.yml b/.github/workflows/speed-comparison.yml
index 27c762a3e4..eaa23914da 100644
--- a/.github/workflows/speed-comparison.yml
+++ b/.github/workflows/speed-comparison.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -34,7 +34,7 @@ jobs:
working-directory: "tools/speed-comparison/UnifiedTests"
- name: Upload Build Artifacts
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: test-builds-ubuntu
path: tools/speed-comparison/UnifiedTests/bin/
@@ -53,7 +53,7 @@ jobs:
cancel-in-progress: true
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -63,7 +63,7 @@ jobs:
dotnet-version: 10.0.x
- name: Download Build Artifacts
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@v7
with:
name: test-builds-ubuntu
path: tools/speed-comparison/UnifiedTests/bin/
@@ -81,7 +81,7 @@ jobs:
CLASS_NAME: ${{ matrix.class }}
- name: Upload Markdown
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: ubuntu_markdown_run_time_${{ matrix.class }}
@@ -97,7 +97,7 @@ jobs:
cancel-in-progress: true
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -116,7 +116,7 @@ jobs:
working-directory: "tools/speed-comparison/Tests.Benchmark"
- name: Upload Markdown
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: ubuntu_markdown_build_time
@@ -132,20 +132,20 @@ jobs:
pull-requests: write
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.ADMIN_TOKEN }}
- name: Download All Runtime Benchmark Artifacts
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@v7
with:
path: benchmark-results/runtime/
pattern: ubuntu_markdown_run_time_*
merge-multiple: false
- name: Download Build Time Benchmark Artifacts
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@v7
with:
path: benchmark-results/build/
pattern: ubuntu_markdown_build_time
@@ -161,7 +161,7 @@ jobs:
node .github/scripts/process-benchmarks.js
- name: Upload Individual Runtime Benchmarks
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: benchmark-DataDrivenTests
@@ -171,7 +171,7 @@ jobs:
retention-days: 90
- name: Upload Individual Runtime Benchmarks
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: benchmark-AsyncTests
@@ -181,7 +181,7 @@ jobs:
retention-days: 90
- name: Upload Individual Runtime Benchmarks
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: benchmark-ScaleTests
@@ -191,7 +191,7 @@ jobs:
retention-days: 90
- name: Upload Individual Runtime Benchmarks
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: benchmark-MatrixTests
@@ -201,7 +201,7 @@ jobs:
retention-days: 90
- name: Upload Individual Runtime Benchmarks
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: benchmark-MassiveParallelTests
@@ -211,7 +211,7 @@ jobs:
retention-days: 90
- name: Upload Individual Runtime Benchmarks
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: benchmark-SetupTeardownTests
@@ -221,7 +221,7 @@ jobs:
retention-days: 90
- name: Upload Build Benchmark
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: benchmark-BuildTime
@@ -231,7 +231,7 @@ jobs:
retention-days: 90
- name: Upload Summary Files
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
if: always()
with:
name: benchmark-summary
@@ -278,7 +278,7 @@ jobs:
- name: Create Pull Request
if: steps.check_changes.outputs.has_changes == 'true'
id: create_pr
- uses: peter-evans/create-pull-request@v7
+ uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.ADMIN_TOKEN }}
commit-message: 'chore: update benchmark results'
diff --git a/.gitignore b/.gitignore
index 0e3c8cfe18..149ec04978 100644
--- a/.gitignore
+++ b/.gitignore
@@ -419,8 +419,8 @@ TUnit.TestProject/TestSession*.txt
.mcp.json
requirements
-TESTPROJECT_AOT
-TESTPROJECT_SINGLEFILE
+TESTPROJECT_AOT*
+TESTPROJECT_SINGLEFILE*
nul
diff --git a/.idea/.idea.TUnit/.idea/copilot.data.migration.ask.xml b/.idea/.idea.TUnit/.idea/copilot.data.migration.ask.xml
deleted file mode 100644
index 7ef04e2ea0..0000000000
--- a/.idea/.idea.TUnit/.idea/copilot.data.migration.ask.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.TUnit/.idea/copilot.data.migration.ask2agent.xml b/.idea/.idea.TUnit/.idea/copilot.data.migration.ask2agent.xml
deleted file mode 100644
index 1f2ea11e7f..0000000000
--- a/.idea/.idea.TUnit/.idea/copilot.data.migration.ask2agent.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.TUnit/.idea/indexLayout.xml b/.idea/.idea.TUnit/.idea/indexLayout.xml
deleted file mode 100644
index 7b08163ceb..0000000000
--- a/.idea/.idea.TUnit/.idea/indexLayout.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.serena/.gitignore b/.serena/.gitignore
deleted file mode 100644
index 14d86ad623..0000000000
--- a/.serena/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/cache
diff --git a/.serena/cache/csharp/document_symbols_cache_v20-05-25.pkl b/.serena/cache/csharp/document_symbols_cache_v20-05-25.pkl
deleted file mode 100644
index 091ba63e6d..0000000000
Binary files a/.serena/cache/csharp/document_symbols_cache_v20-05-25.pkl and /dev/null differ
diff --git a/.serena/cache/csharp/document_symbols_cache_v23-06-25.pkl b/.serena/cache/csharp/document_symbols_cache_v23-06-25.pkl
deleted file mode 100644
index 1116471997..0000000000
Binary files a/.serena/cache/csharp/document_symbols_cache_v23-06-25.pkl and /dev/null differ
diff --git a/.serena/memories/code_style_conventions.md b/.serena/memories/code_style_conventions.md
deleted file mode 100644
index f8715af5f1..0000000000
--- a/.serena/memories/code_style_conventions.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# TUnit Code Style and Conventions
-
-## General Guidelines
-- **Self-Descriptive Code**: Write self-descriptive code instead of adding redundant comments
-- **Clean Architecture**: Maintain separation between source generation (data only) and runtime (logic)
-- **Async Best Practices**: Never use `.Result`, `.Wait()`, or `GetAwaiter().GetResult()` - always use proper async/await
-
-## Async Guidelines (Critical)
-- **Never Block on Async**: Avoid `GetAwaiter().GetResult()`, `.Result`, or `.Wait()` to prevent deadlocks
-- **Async All the Way**: When in sync context needing async, refactor method signatures to be async
-- **Embrace Async**: TUnit supports async throughout the stack - use it properly
-- **Proper Cancellation**: Use CancellationToken properly for long-running operations
-
-## Architecture Patterns
-- **Source Generators**: Should only emit data structures (TestMetadata), never execution logic
-- **Runtime Phase**: TestBuilder handles complex logic (data expansion, tuple unwrapping, etc.)
-- **Performance Focus**: Use expression compilation and caching in TestBuilder
-- **Extension Points**: Support custom executors, data sources, hooks, and assertions
-
-## Project Structure
-- Core logic in `TUnit.Core`
-- Source generation in `TUnit.Core.SourceGenerator`
-- Test execution in `TUnit.Engine`
-- Assertions in `TUnit.Assertions`
-- Tests in `TUnit.UnitTests` (framework) and `TUnit.TestProject` (integration)
-
-## Testing Conventions
-- Use TUnit for framework testing
-- Add analyzer rules when adding new features
-- Ensure Microsoft.Testing.Platform compatibility
\ No newline at end of file
diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md
deleted file mode 100644
index 01500708de..0000000000
--- a/.serena/memories/project_overview.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# TUnit Project Overview
-
-## Purpose
-TUnit is a modern, source-generated testing framework (with a reflection-based fallback) for .NET that provides:
-- Compile-time test discovery through source generators
-- Parallel test execution by default
-- Native AOT and trimming support
-- Built on Microsoft.Testing.Platform
-- Fluent assertion library
-- Rich extensibility model
-
-## Tech Stack
-- **Language**: C#
-- **Target Frameworks**: .NET 8.0 and .NET 9.0
-- **SDK Requirement**: .NET SDK 9.0.301 or later (see global.json)
-- **Architecture**: Source generation + runtime execution engine
-- **Testing Platform**: Microsoft.Testing.Platform integration
-
-## Core Components
-1. **TUnit.Core**: Core abstractions and attributes
-2. **TUnit.Core.SourceGenerator**: Source generators for compile-time test discovery
-3. **TUnit.Engine**: Test execution engine with simplified architecture
-4. **TUnit.Assertions**: Fluent assertion library with async support
-5. **Extension Projects**: Playwright, F#, Analyzers, Templates
-
-## Key Features
-- **Clean Architecture**: Separation between source generation (data) and runtime (logic)
-- **Parallel-First**: Tests run in parallel by default with smart scheduling
-- **Async Support**: All public APIs support async operations
-- **Extensible**: Custom executors, data sources, hooks, and assertions
-- **Dual Mode**: TUnit supports Source Generated mode and Reflection Mode, and both should maintain feature parity, with the exception of the source generated code path MUST support Native AOT and trimming
diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md
deleted file mode 100644
index fc9781b920..0000000000
--- a/.serena/memories/suggested_commands.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Suggested Commands for TUnit Development
-
-## Build Commands
-```bash
-# Debug build
-dotnet build
-
-# Release build
-dotnet build -c Release
-
-# Clean build artifacts
-./clean.ps1
-
-# Build NuGet packages
-dotnet pack -c Release
-```
-
-## Test Execution Commands
-```bash
-# Run tests in a project (3 equivalent ways)
-cd [TestProjectDirectory]
-dotnet run -c Release
-dotnet test -c Release
-dotnet exec bin/Release/net8.0/TestProject.dll
-
-# Test options
-dotnet run -- --list-tests # List all tests
-dotnet run -- --fail-fast # Stop on first failure
-dotnet run -- --maximum-parallel-tests 10 # Control parallelism
-dotnet run -- --report-trx --coverage # Generate reports
-dotnet run -- --treenode-filter "/*/*/*/TestName" # Run specific test by exact name
-dotnet run -- --treenode-filter/*/*/*PartialName*/* # Filter tests by partial name pattern
-```
-
-## Development Commands
-```bash
-# Run full pipeline
-dotnet run --project TUnit.Pipeline/TUnit.Pipeline.csproj
-
-# Documentation site
-cd docs
-npm install # First time only
-npm start # Run locally at localhost:3000
-npm run build # Build static site
-```
-
-## System Commands (Linux)
-- `ls` - List directory contents
-- `find` - Search for files/directories
-- `grep` - Search text in files
-- `git` - Version control operations
-- `cd` - Change directory
diff --git a/.serena/memories/task_completion_checklist.md b/.serena/memories/task_completion_checklist.md
deleted file mode 100644
index 99dcdb05be..0000000000
--- a/.serena/memories/task_completion_checklist.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Task Completion Checklist
-
-When completing a coding task in TUnit:
-
-## Code Quality
-- [ ] Follow async best practices (no `.Result`, `.Wait()`, `GetAwaiter().GetResult()`)
-- [ ] Write self-descriptive code without redundant comments
-- [ ] Maintain clean architecture separation (source gen vs runtime)
-- [ ] Use proper error handling and logging
-- [ ] Do not over-engineer
-- [ ] Follow C# best practices, as well as principles such as DRY, KISS, SRP and SOLID
-
-## Testing
-- [ ] Run relevant tests: `dotnet run -c Release` in test project directory
-- [ ] Add/update unit tests in `TUnit.UnitTests` for framework changes
-- [ ] Add/update integration tests in `TUnit.TestProject` for end-to-end scenarios and add the [EngineTest(ExpectedResult.Pass)] attribute so the pipeline knows to run these tests and they must pass
-- [ ] Verify no hanging processes or infinite loops
-
-## Architecture Compliance
-- [ ] Source generators only emit data, not execution logic
-- [ ] Runtime components handle all complex logic
-- [ ] Maintain Microsoft.Testing.Platform compatibility
-- [ ] Ensure proper async support throughout
-- [ ] Ensure feature parity between source generation mode and reflection mode
-
-## Performance & Reliability
-- [ ] Check for potential deadlocks or blocking operations
-- [ ] Verify proper resource disposal
-- [ ] Test cancellation token handling
-- [ ] Validate timeout mechanisms work correctly
-- [ ] Ensure code is performant
-
-## Documentation (if applicable)
-- [ ] Update relevant documentation in `docs/` if behavior changes
-- [ ] If building a new feature, add new documentation in `docs/` in the relevant location with clear, easy to read language and code examples
-- [ ] Update CLAUDE.md if architectural patterns change
-
-## Final Verification
-- [ ] Build solution: `dotnet build -c Release`
-- [ ] Run full test suite
-- [ ] Check for memory leaks or hanging processes
diff --git a/.serena/project.yml b/.serena/project.yml
deleted file mode 100644
index 5a63a94911..0000000000
--- a/.serena/project.yml
+++ /dev/null
@@ -1,66 +0,0 @@
-# language of the project (csharp, python, rust, java, typescript, javascript, go, cpp, or ruby)
-# Special requirements:
-# * csharp: Requires the presence of a .sln file in the project folder.
-language: csharp
-
-# whether to use the project's gitignore file to ignore files
-# Added on 2025-04-07
-ignore_all_files_in_gitignore: true
-# list of additional paths to ignore
-# same syntax as gitignore, so you can use * and **
-# Was previously called `ignored_dirs`, please update your config if you are using that.
-# Added (renamed)on 2025-04-07
-ignored_paths: []
-
-# whether the project is in read-only mode
-# If set to true, all editing tools will be disabled and attempts to use them will result in an error
-# Added on 2025-04-18
-read_only: false
-
-
-# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
-# Below is the complete list of tools for convenience.
-# To make sure you have the latest list of tools, and to view their descriptions,
-# execute `uv run scripts/print_tool_overview.py`.
-#
-# * `activate_project`: Activates a project by name.
-# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
-# * `create_text_file`: Creates/overwrites a file in the project directory.
-# * `delete_lines`: Deletes a range of lines within a file.
-# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
-# * `execute_shell_command`: Executes a shell command.
-# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
-# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
-# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
-# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
-# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file or directory.
-# * `initial_instructions`: Gets the initial instructions for the current project.
-# Should only be used in settings where the system prompt cannot be set,
-# e.g. in clients you have no control over, like Claude Desktop.
-# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
-# * `insert_at_line`: Inserts content at a given line in a file.
-# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
-# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
-# * `list_memories`: Lists memories in Serena's project-specific memory store.
-# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
-# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
-# * `read_file`: Reads a file within the project directory.
-# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
-# * `remove_project`: Removes a project from the Serena configuration.
-# * `replace_lines`: Replaces a range of lines within a file with new content.
-# * `replace_symbol_body`: Replaces the full definition of a symbol.
-# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
-# * `search_for_pattern`: Performs a search for a pattern in the project.
-# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
-# * `switch_modes`: Activates modes by providing a list of their names
-# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
-# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
-# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
-# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
-excluded_tools: []
-
-# initial prompt for the project. It will always be given to the LLM upon activating the project
-# (contrary to the memories, which are loaded on demand).
-initial_prompt: ""
-
-project_name: "TUnit"
diff --git a/CLAUDE.md b/CLAUDE.md
index 1ed9227dd1..925f3571f6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -147,6 +147,17 @@ dotnet test TUnit.Core.SourceGenerator.Tests
**Rule**: Only run TUnit.TestProject with explicit `--treenode-filter` to target specific tests or classes.
+**IMPORTANT: Run filters ONE AT A TIME!** Using OR patterns (`Pattern1|Pattern2`) can match thousands of unintended tests. Always run one specific filter per command:
+
+```bash
+# ❌ WRONG - OR patterns can match too broadly
+--treenode-filter "/*/*/ClassA/*|/*/*/ClassB/*"
+
+# ✅ CORRECT - Run separate commands for each class
+dotnet run -- --treenode-filter "/*/*/ClassA/*"
+dotnet run -- --treenode-filter "/*/*/ClassB/*"
+```
+
---
### Most Common Commands
diff --git a/Directory.Packages.props b/Directory.Packages.props
index cc33abe666..11f44f281e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,18 +7,18 @@
-
-
+
+
-
+
-
-
-
+
+
+
-
-
-
+
+
+
@@ -32,18 +32,18 @@
-
-
+
+
-
-
+
+
-
+
@@ -52,15 +52,15 @@
-
+
-
+
-
-
-
+
+
+
@@ -73,26 +73,26 @@
-
+
-
-
-
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
\ No newline at end of file
diff --git a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs
index 3683793e41..e28299d735 100644
--- a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs
+++ b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs
@@ -5,6 +5,7 @@
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;
+using Microsoft.CodeAnalysis.Text;
namespace TUnit.Analyzers.Tests.Verifiers;
@@ -16,6 +17,12 @@ public class Test : CSharpCodeFixTest
{
var project = solution.GetProject(projectId);
diff --git a/TUnit.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs b/TUnit.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs
index e12d6722ca..cecbb774c4 100644
--- a/TUnit.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs
+++ b/TUnit.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs
@@ -60,7 +60,17 @@ public void NotEmpty(string collectionName, IEnumerable collection)
public void SequenceEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer? equalityComparer = null, string? message = null)
{
- _defaultVerifier.SequenceEqual(expected, actual, equalityComparer, message);
+ // Normalize line endings for string sequence comparisons
+ if (typeof(T) == typeof(string))
+ {
+ var normalizedExpected = expected.Cast().Select(NormalizeLineEndings).Cast();
+ var normalizedActual = actual.Cast().Select(NormalizeLineEndings).Cast();
+ _defaultVerifier.SequenceEqual(normalizedExpected, normalizedActual, equalityComparer, message);
+ }
+ else
+ {
+ _defaultVerifier.SequenceEqual(expected, actual, equalityComparer, message);
+ }
}
public IVerifier PushContext(string context)
diff --git a/TUnit.Assertions.FSharp/TUnit.Assertions.FSharp.fsproj b/TUnit.Assertions.FSharp/TUnit.Assertions.FSharp.fsproj
index 7eeceee31c..2c94708a04 100644
--- a/TUnit.Assertions.FSharp/TUnit.Assertions.FSharp.fsproj
+++ b/TUnit.Assertions.FSharp/TUnit.Assertions.FSharp.fsproj
@@ -6,6 +6,7 @@
+
diff --git a/TUnit.Assertions.FSharp/TaskAssert.fs b/TUnit.Assertions.FSharp/TaskAssert.fs
new file mode 100644
index 0000000000..3689e47d70
--- /dev/null
+++ b/TUnit.Assertions.FSharp/TaskAssert.fs
@@ -0,0 +1,15 @@
+module TUnit.Assertions.FSharp.TaskAssert
+
+open TUnit.Assertions.Core
+
+[]
+module TaskAssertBuilder =
+ let taskAssert = task
+
+[]
+module TaskAssertCEExtensions =
+ type TaskBuilderBase with
+ #nowarn "FS1204"
+ member inline x.Bind(assertion: IAssertion, continuation: Unit -> TaskCode<'TOverall, 'TResult2>) : TaskCode<'TOverall, 'TResult2> =
+ let task = assertion.AssertAsync()
+ x.Bind(task, continuation)
diff --git a/TUnit.Assertions.Tests/AssertConditions/BecauseTests.cs b/TUnit.Assertions.Tests/AssertConditions/BecauseTests.cs
index 1d44b17e48..0c4c88137f 100644
--- a/TUnit.Assertions.Tests/AssertConditions/BecauseTests.cs
+++ b/TUnit.Assertions.Tests/AssertConditions/BecauseTests.cs
@@ -1,4 +1,4 @@
-namespace TUnit.Assertions.Tests.AssertConditions;
+namespace TUnit.Assertions.Tests.AssertConditions;
public class BecauseTests
{
@@ -68,7 +68,7 @@ at Assert.That(variable).IsFalse()
};
var exception = await Assert.ThrowsAsync(action);
- await Assert.That(exception.Message).IsEqualTo(expectedMessage);
+ await Assert.That(exception.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage.NormalizeLineEndings());
}
[Test]
@@ -91,7 +91,7 @@ await Assert.That(variable).IsTrue().Because(because)
};
var exception = await Assert.ThrowsAsync(action);
- await Assert.That(exception.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
+ await Assert.That(exception.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage.NormalizeLineEndings());
}
[Test]
diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExactlyTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExactlyTests.cs
index c3292ec78e..35d744499e 100644
--- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExactlyTests.cs
+++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExactlyTests.cs
@@ -13,15 +13,15 @@ public async Task Fails_For_Code_With_Other_Exceptions()
but threw TUnit.Assertions.Tests.Assertions.Delegates.Throws+OtherException
at Assert.That(action).ThrowsExactly()
- """;
+ """.NormalizeLineEndings();
Exception exception = CreateOtherException();
Action action = () => throw exception;
var sut = async ()
=> await Assert.That(action).ThrowsExactly();
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
@@ -32,15 +32,15 @@ public async Task Fails_For_Code_With_Subtype_Exceptions()
but wrong exception type: SubCustomException instead of exactly CustomException
at Assert.That(action).ThrowsExactly()
- """;
+ """.NormalizeLineEndings();
Exception exception = CreateSubCustomException();
Action action = () => throw exception;
var sut = async ()
=> await Assert.That(action).ThrowsExactly();
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
@@ -51,14 +51,14 @@ public async Task Fails_For_Code_Without_Exceptions()
but no exception was thrown
at Assert.That(action).ThrowsExactly()
- """;
+ """.NormalizeLineEndings();
var action = () => { };
var sut = async ()
=> await Assert.That(action).ThrowsExactly();
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
@@ -117,10 +117,11 @@ public async Task Conversion_To_Value_Assertion_Builder_On_Casted_Exception_Type
await Assert.That((object)ex).IsAssignableTo();
});
- await Assert.That(assertionException).HasMessageStartingWith("""
- Expected to throw exactly Exception
- but wrong exception type: CustomException instead of exactly Exception
- """);
+ var expectedPrefix = """
+ Expected to throw exactly Exception
+ but wrong exception type: CustomException instead of exactly Exception
+ """.NormalizeLineEndings();
+ await Assert.That(assertionException.Message.NormalizeLineEndings()).StartsWith(expectedPrefix);
}
[Test]
diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExceptionTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExceptionTests.cs
index cedfa0b176..3b51f1e29e 100644
--- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExceptionTests.cs
+++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExceptionTests.cs
@@ -14,14 +14,14 @@ public async Task Fails_For_Code_Without_Exceptions()
but no exception was thrown
at Assert.That(action).ThrowsException()
- """;
+ """.NormalizeLineEndings();
var action = () => { };
var sut = async ()
=> await Assert.That(action).ThrowsException();
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.NothingTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.NothingTests.cs
index 48077a848d..29fc9e7d33 100644
--- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.NothingTests.cs
+++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.NothingTests.cs
@@ -12,15 +12,15 @@ public async Task Fails_For_Code_With_Exceptions()
but threw TUnit.Assertions.Tests.Assertions.Delegates.Throws+CustomException: {nameof(Fails_For_Code_With_Exceptions)}
at Assert.That(action).ThrowsNothing()
- """;
+ """.NormalizeLineEndings();
Exception exception = CreateCustomException();
Action action = () => throw exception;
var sut = async ()
=> await Assert.That(action).ThrowsNothing();
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.OfTypeTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.OfTypeTests.cs
index a900ceaff2..ef4c853221 100644
--- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.OfTypeTests.cs
+++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.OfTypeTests.cs
@@ -13,15 +13,15 @@ public async Task Fails_For_Code_With_Other_Exceptions()
but threw TUnit.Assertions.Tests.Assertions.Delegates.Throws+OtherException
at Assert.That(action).Throws()
- """;
+ """.NormalizeLineEndings();
Exception exception = CreateOtherException();
Action action = () => throw exception;
var sut = async ()
=> await Assert.That(action).Throws();
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
@@ -32,15 +32,15 @@ public async Task Fails_For_Code_With_Supertype_Exceptions()
but threw TUnit.Assertions.Tests.Assertions.Delegates.Throws+CustomException
at Assert.That(action).Throws()
- """;
+ """.NormalizeLineEndings();
Exception exception = CreateCustomException();
Action action = () => throw exception;
var sut = async ()
=> await Assert.That(action).Throws();
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
@@ -51,14 +51,14 @@ public async Task Fails_For_Code_Without_Exceptions()
but no exception was thrown
at Assert.That(action).Throws()
- """;
+ """.NormalizeLineEndings();
var action = () => { };
var sut = async ()
=> await Assert.That(action).Throws();
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionTests.cs
index 88386844cd..2b167014c5 100644
--- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionTests.cs
+++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionTests.cs
@@ -15,7 +15,7 @@ Expected exception message to equal "bar"
but exception message was "some different inner message"
at Assert.That(action).ThrowsException().WithInnerException().WithMessage("bar")
- """;
+ """.NormalizeLineEndings();
Exception exception = CreateCustomException(outerMessage,
CreateCustomException("some different inner message"));
Action action = () => throw exception;
@@ -24,8 +24,8 @@ at Assert.That(action).ThrowsException().WithInnerException().WithMessage("bar")
=> await Assert.That(action).ThrowsException()
.WithInnerException().WithMessage(expectedInnerMessage);
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageMatchingTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageMatchingTests.cs
index 8401907110..0d8e0b37ff 100644
--- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageMatchingTests.cs
+++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageMatchingTests.cs
@@ -42,15 +42,15 @@ Expected exception message to match pattern "bar"
but exception message "foo" does not match pattern "bar"
at Assert.That(action).ThrowsExactly().WithMessageMatching("bar")
- """;
+ """.NormalizeLineEndings();
Exception exception = CreateCustomException(message1);
Action action = () => throw exception;
var sut = async ()
=> await Assert.That(action).ThrowsExactly().WithMessageMatching(message2);
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageTests.cs
index d444dc3372..293d109d1e 100644
--- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageTests.cs
+++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageTests.cs
@@ -15,15 +15,15 @@ Expected exception message to equal "bar"
but exception message was "foo"
at Assert.That(action).ThrowsExactly().WithMessage("bar")
- """;
+ """.NormalizeLineEndings();
Exception exception = CreateCustomException(message1);
Action action = () => throw exception;
var sut = async ()
=> await Assert.That(action).ThrowsExactly().WithMessage(message2);
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithParameterNameTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithParameterNameTests.cs
index 41c2ca56c0..70c0c658f9 100644
--- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithParameterNameTests.cs
+++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithParameterNameTests.cs
@@ -15,15 +15,15 @@ public async Task Fails_For_Different_Parameter_Name()
but ArgumentException parameter name was "foo"
at Assert.That(action).ThrowsExactly().WithParameterName("bar")
- """;
+ """.NormalizeLineEndings();
ArgumentException exception = new(string.Empty, paramName1);
Action action = () => throw exception;
var sut = async ()
=> await Assert.That(action).ThrowsExactly().WithParameterName(paramName2);
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var thrownException = await Assert.That(sut).ThrowsException();
+ await Assert.That(thrownException.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
diff --git a/TUnit.Assertions.Tests/Bugs/Tests2117.cs b/TUnit.Assertions.Tests/Bugs/Tests2117.cs
index df5fcad7cb..6fe04798e3 100644
--- a/TUnit.Assertions.Tests/Bugs/Tests2117.cs
+++ b/TUnit.Assertions.Tests/Bugs/Tests2117.cs
@@ -28,12 +28,13 @@ at Assert.That(a).IsEquivalentTo(b)
""")]
public async Task IsEquivalent_Fail(int[] a, int[] b, CollectionOrdering? collectionOrdering, string expectedError)
{
- await Assert.That(async () =>
+ var exception = await Assert.That(async () =>
await (collectionOrdering is null
? Assert.That(a).IsEquivalentTo(b)
: Assert.That(a).IsEquivalentTo(b, collectionOrdering.Value))
- ).Throws()
- .WithMessage(expectedError);
+ ).Throws();
+
+ await Assert.That(exception.Message.NormalizeLineEndings()).IsEqualTo(expectedError.NormalizeLineEndings());
}
[Test]
@@ -60,11 +61,12 @@ at Assert.That(a).IsNotEquivalentTo(b)
""")]
public async Task IsNotEquivalent_Fail(int[] a, int[] b, CollectionOrdering? collectionOrdering, string expectedError)
{
- await Assert.That(async () =>
+ var exception = await Assert.That(async () =>
await (collectionOrdering is null
? Assert.That(a).IsNotEquivalentTo(b)
: Assert.That(a).IsNotEquivalentTo(b, collectionOrdering.Value))
- ).Throws()
- .WithMessage(expectedError);
+ ).Throws();
+
+ await Assert.That(exception.Message.NormalizeLineEndings()).IsEqualTo(expectedError.NormalizeLineEndings());
}
}
diff --git a/TUnit.Assertions.Tests/CollectionAssertionTests.cs b/TUnit.Assertions.Tests/CollectionAssertionTests.cs
new file mode 100644
index 0000000000..ffb9a9b92d
--- /dev/null
+++ b/TUnit.Assertions.Tests/CollectionAssertionTests.cs
@@ -0,0 +1,273 @@
+using TUnit.Assertions.Extensions;
+
+namespace TUnit.Assertions.Tests;
+
+public class CollectionAssertionTests
+{
+ [Test]
+ public async Task IsEmpty()
+ {
+ var items = new List();
+
+ await Assert.That(items).IsEmpty();
+ }
+
+ [Test]
+ public async Task IsEmpty2()
+ {
+ var items = new List();
+
+ await Assert.That(() => items).IsEmpty();
+ }
+
+ [Test]
+ public async Task Count()
+ {
+ var items = new List();
+
+ await Assert.That(items).Count().IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task Count2()
+ {
+ var items = new List();
+
+ await Assert.That(() => items).Count().IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_IsGreaterThan()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count items where item > 2 using inner assertion builder
+ await Assert.That(items).Count(item => item.IsGreaterThan(2)).IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_IsLessThan()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count items where item < 4 using inner assertion builder
+ await Assert.That(items).Count(item => item.IsLessThan(4)).IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_IsBetween()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count items where 2 <= item <= 4 using inner assertion builder
+ await Assert.That(items).Count(item => item.IsBetween(2, 4)).IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_String_Contains()
+ {
+ var items = new List { "apple", "banana", "apricot", "cherry" };
+
+ // Count items that contain "ap" using inner assertion builder
+ await Assert.That(items).Count(item => item.Contains("ap")).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_String_StartsWith()
+ {
+ var items = new List { "apple", "banana", "apricot", "cherry" };
+
+ // Count items that start with "a" using inner assertion builder
+ await Assert.That(items).Count(item => item.StartsWith("a")).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_EmptyCollection()
+ {
+ var items = new List();
+
+ // Count on empty collection should return 0
+ await Assert.That(items).Count(item => item.IsGreaterThan(0)).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_NoneMatch()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count items > 10 (none match)
+ await Assert.That(items).Count(item => item.IsGreaterThan(10)).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_AllMatch()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count items > 0 (all match)
+ await Assert.That(items).Count(item => item.IsGreaterThan(0)).IsEqualTo(5);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_Lambda_Collection()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Test with lambda-wrapped collection
+ await Assert.That(() => items).Count(item => item.IsGreaterThan(2)).IsEqualTo(3);
+ }
+
+ // Tests for collection chaining after Count assertions
+
+ [Test]
+ public async Task Count_ThenAnd_Contains()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count and then chain with Contains
+ await Assert.That(items)
+ .Count().IsEqualTo(5)
+ .And.Contains(3);
+ }
+
+ [Test]
+ public async Task Count_ThenAnd_IsNotEmpty()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count and then chain with IsNotEmpty
+ await Assert.That(items)
+ .Count().IsGreaterThan(0)
+ .And.IsNotEmpty();
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_ThenAnd_Contains()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count with inner assertion and then chain with Contains
+ await Assert.That(items)
+ .Count(item => item.IsGreaterThan(2)).IsEqualTo(3)
+ .And.Contains(5);
+ }
+
+ [Test]
+ public async Task Count_ThenAnd_All()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count and then chain with All
+ await Assert.That(items)
+ .Count().IsEqualTo(5)
+ .And.All(x => x > 0);
+ }
+
+ [Test]
+ public async Task Count_ThenAnd_Count()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Chain multiple Count assertions
+ await Assert.That(items)
+ .Count().IsGreaterThan(3)
+ .And.Count().IsLessThan(10);
+ }
+
+ [Test]
+ public async Task Count_WithInnerAssertion_ThenAnd_IsInOrder()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ // Count with inner assertion and then check ordering
+ await Assert.That(items)
+ .Count(item => item.IsGreaterThan(0)).IsEqualTo(5)
+ .And.IsInOrder();
+ }
+
+ [Test]
+ public async Task Count_IsGreaterThan()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ await Assert.That(items).Count().IsGreaterThan(3);
+ }
+
+ [Test]
+ public async Task Count_IsLessThan()
+ {
+ var items = new List { 1, 2, 3 };
+
+ await Assert.That(items).Count().IsLessThan(5);
+ }
+
+ [Test]
+ public async Task Count_IsGreaterThanOrEqualTo()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ await Assert.That(items).Count().IsGreaterThanOrEqualTo(5);
+ }
+
+ [Test]
+ public async Task Count_IsLessThanOrEqualTo()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+
+ await Assert.That(items).Count().IsLessThanOrEqualTo(5);
+ }
+
+ [Test]
+ public async Task Count_IsZero()
+ {
+ var items = new List();
+
+ await Assert.That(items).Count().IsZero();
+ }
+
+ [Test]
+ public async Task Count_IsPositive()
+ {
+ var items = new List { 1 };
+
+ await Assert.That(items).Count().IsPositive();
+ }
+
+ [Test]
+ public async Task Count_IsNotEqualTo()
+ {
+ var items = new List { 1, 2, 3 };
+
+ await Assert.That(items).Count().IsNotEqualTo(5);
+ }
+
+ [Test]
+ public async Task Chained_Collection_Assertions()
+ {
+ var numbers = new[] { 1, 2, 3, 4, 5 };
+
+ // For collections of int, use Count().IsEqualTo(5) instead of Count(c => c.IsEqualTo(5))
+ // to avoid ambiguity with item-filtering
+ await Assert.That(numbers)
+ .IsNotEmpty()
+ .And.Count().IsEqualTo(5)
+ .And.Contains(3)
+ .And.DoesNotContain(10)
+ .And.IsInOrder()
+ .And.All(n => n > 0)
+ .And.Any(n => n == 5);
+ }
+
+ [Test]
+ public async Task Chained_Collection_Assertions_WithStrings()
+ {
+ var names = new[] { "Alice", "Bob", "Charlie" };
+
+ // For non-int collections, Count(c => c.IsEqualTo(3)) works unambiguously
+ await Assert.That(names)
+ .IsNotEmpty()
+ .And.Count(c => c.IsEqualTo(3))
+ .And.Contains("Bob")
+ .And.DoesNotContain("Dave");
+ }
+}
diff --git a/TUnit.Assertions.Tests/FuncCollectionAssertionTests.cs b/TUnit.Assertions.Tests/FuncCollectionAssertionTests.cs
new file mode 100644
index 0000000000..ffa4fee039
--- /dev/null
+++ b/TUnit.Assertions.Tests/FuncCollectionAssertionTests.cs
@@ -0,0 +1,229 @@
+using TUnit.Assertions.Exceptions;
+
+namespace TUnit.Assertions.Tests;
+
+///
+/// Tests for FuncCollectionAssertion - verifies that collection assertions work when
+/// collections are wrapped in lambdas. Addresses GitHub issue #3910.
+///
+public class FuncCollectionAssertionTests
+{
+ [Test]
+ public async Task Lambda_Collection_IsEmpty_Passes_For_Empty_Collection()
+ {
+ var items = new List();
+ await Assert.That(() => items).IsEmpty();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_IsEmpty_Fails_For_NonEmpty_Collection()
+ {
+ var items = new List { 1, 2, 3 };
+ await Assert.That(async () => await Assert.That(() => items).IsEmpty())
+ .Throws();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_IsNotEmpty_Passes_For_NonEmpty_Collection()
+ {
+ var items = new List { 1, 2, 3 };
+ await Assert.That(() => items).IsNotEmpty();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_IsNotEmpty_Fails_For_Empty_Collection()
+ {
+ var items = new List();
+ await Assert.That(async () => await Assert.That(() => items).IsNotEmpty())
+ .Throws();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_Count_IsEqualTo_Passes()
+ {
+ var items = new List { 1, 2, 3 };
+ await Assert.That(() => items).Count().IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task Lambda_Collection_Count_IsGreaterThan_Passes()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+ await Assert.That(() => items).Count().IsGreaterThan(3);
+ }
+
+ [Test]
+ public async Task Lambda_Collection_Contains_Passes()
+ {
+ var items = new List { 1, 2, 3 };
+ await Assert.That(() => items).Contains(2);
+ }
+
+ [Test]
+ public async Task Lambda_Collection_Contains_Fails_When_Item_Not_Present()
+ {
+ var items = new List { 1, 2, 3 };
+ await Assert.That(async () => await Assert.That(() => items).Contains(99))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_DoesNotContain_Passes()
+ {
+ var items = new List { 1, 2, 3 };
+ await Assert.That(() => items).DoesNotContain(99);
+ }
+
+ [Test]
+ public async Task Lambda_Collection_HasSingleItem_Passes()
+ {
+ var items = new List { 42 };
+ await Assert.That(() => items).HasSingleItem();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_All_Passes()
+ {
+ var items = new List { 2, 4, 6 };
+ await Assert.That(() => items).All(x => x % 2 == 0);
+ }
+
+ [Test]
+ public async Task Lambda_Collection_Any_Passes()
+ {
+ var items = new List { 1, 2, 3 };
+ await Assert.That(() => items).Any(x => x > 2);
+ }
+
+ [Test]
+ public async Task Lambda_Collection_IsInOrder_Passes()
+ {
+ var items = new List { 1, 2, 3, 4, 5 };
+ await Assert.That(() => items).IsInOrder();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_IsInDescendingOrder_Passes()
+ {
+ var items = new List { 5, 4, 3, 2, 1 };
+ await Assert.That(() => items).IsInDescendingOrder();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_HasDistinctItems_Passes()
+ {
+ var items = new List { 1, 2, 3 };
+ await Assert.That(() => items).HasDistinctItems();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_Throws_Passes_When_Exception_Thrown()
+ {
+ await Assert.That(() => ThrowingMethod()).Throws();
+ }
+
+ [Test]
+ public async Task Lambda_Collection_Chaining_With_And()
+ {
+ var items = new List { 1, 2, 3 };
+ await Assert.That(() => items)
+ .IsNotEmpty()
+ .And.Contains(2)
+ .And.HasDistinctItems();
+ }
+
+ [Test]
+ public async Task Lambda_Array_IsEmpty_Passes()
+ {
+ var items = Array.Empty();
+ await Assert.That(() => items).IsEmpty();
+ }
+
+ [Test]
+ public async Task Lambda_Array_IsNotEmpty_Passes()
+ {
+ var items = new[] { "a", "b", "c" };
+ await Assert.That(() => items).IsNotEmpty();
+ }
+
+ [Test]
+ public async Task Lambda_Enumerable_IsEmpty_Passes()
+ {
+ IEnumerable items = Enumerable.Empty();
+ await Assert.That(() => items).IsEmpty();
+ }
+
+ [Test]
+ public async Task Lambda_HashSet_Contains_Passes()
+ {
+ var items = new HashSet { 1, 2, 3 };
+ await Assert.That(() => items).Contains(2);
+ }
+
+ private static IEnumerable ThrowingMethod()
+ {
+ throw new InvalidOperationException("Test exception");
+ }
+
+ // Async lambda tests
+ [Test]
+ public async Task AsyncLambda_Collection_IsEmpty_Passes()
+ {
+ await Assert.That(async () => await GetEmptyCollectionAsync()).IsEmpty();
+ }
+
+ [Test]
+ public async Task AsyncLambda_Collection_IsNotEmpty_Passes()
+ {
+ await Assert.That(async () => await GetCollectionAsync()).IsNotEmpty();
+ }
+
+ [Test]
+ public async Task AsyncLambda_Collection_Count_IsEqualTo_Passes()
+ {
+ await Assert.That(async () => await GetCollectionAsync()).Count().IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task AsyncLambda_Collection_Contains_Passes()
+ {
+ await Assert.That(async () => await GetCollectionAsync()).Contains(2);
+ }
+
+ [Test]
+ public async Task AsyncLambda_Collection_All_Passes()
+ {
+ await Assert.That(async () => await GetCollectionAsync()).All(x => x > 0);
+ }
+
+ [Test]
+ public async Task AsyncLambda_Collection_Throws_Passes()
+ {
+ await Assert.That(async () => await ThrowingMethodAsync()).Throws();
+ }
+
+ [Test]
+ public async Task AsyncLambda_Collection_Chaining_With_And()
+ {
+ await Assert.That(async () => await GetCollectionAsync())
+ .IsNotEmpty()
+ .And.Contains(2)
+ .And.HasDistinctItems();
+ }
+
+ private static Task> GetEmptyCollectionAsync()
+ {
+ return Task.FromResult>(new List());
+ }
+
+ private static Task> GetCollectionAsync()
+ {
+ return Task.FromResult>(new List { 1, 2, 3 });
+ }
+
+ private static async Task> ThrowingMethodAsync()
+ {
+ await Task.Yield();
+ throw new InvalidOperationException("Test exception");
+ }
+}
diff --git a/TUnit.Assertions.Tests/Helpers/StringDifferenceTests.cs b/TUnit.Assertions.Tests/Helpers/StringDifferenceTests.cs
index 8aee42cc8e..18d1ef01d7 100644
--- a/TUnit.Assertions.Tests/Helpers/StringDifferenceTests.cs
+++ b/TUnit.Assertions.Tests/Helpers/StringDifferenceTests.cs
@@ -10,15 +10,15 @@ Expected to be equal to "some text"
but found ""
at Assert.That(actual).IsEqualTo(expected)
- """;
+ """.NormalizeLineEndings();
var actual = "";
var expected = "some text";
var sut = async ()
=> await Assert.That(actual).IsEqualTo(expected);
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var exception = await Assert.That(sut).ThrowsException();
+ await Assert.That(exception.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
@@ -29,15 +29,15 @@ Expected to be equal to ""
but found "actual text"
at Assert.That(actual).IsEqualTo(expected)
- """;
+ """.NormalizeLineEndings();
var actual = "actual text";
var expected = "";
var sut = async ()
=> await Assert.That(actual).IsEqualTo(expected);
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var exception = await Assert.That(sut).ThrowsException();
+ await Assert.That(exception.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
@@ -48,15 +48,15 @@ Expected to be equal to "some text"
but found "some"
at Assert.That(actual).IsEqualTo(expected)
- """;
+ """.NormalizeLineEndings();
var actual = "some";
var expected = "some text";
var sut = async ()
=> await Assert.That(actual).IsEqualTo(expected);
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var exception = await Assert.That(sut).ThrowsException();
+ await Assert.That(exception.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
[Test]
@@ -67,14 +67,14 @@ Expected to be equal to "some"
but found "some text"
at Assert.That(actual).IsEqualTo(expected)
- """;
+ """.NormalizeLineEndings();
var actual = "some text";
var expected = "some";
var sut = async ()
=> await Assert.That(actual).IsEqualTo(expected);
- await Assert.That(sut).ThrowsException()
- .WithMessage(expectedMessage);
+ var exception = await Assert.That(sut).ThrowsException();
+ await Assert.That(exception.Message.NormalizeLineEndings()).IsEqualTo(expectedMessage);
}
}
diff --git a/TUnit.Assertions.Tests/Old/AssertMultipleTests.cs b/TUnit.Assertions.Tests/Old/AssertMultipleTests.cs
index a12e4a5421..8f7ff94050 100644
--- a/TUnit.Assertions.Tests/Old/AssertMultipleTests.cs
+++ b/TUnit.Assertions.Tests/Old/AssertMultipleTests.cs
@@ -33,35 +33,35 @@ Expected to be 2
but found 1
at Assert.That(1).IsEqualTo(2)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(exception2.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 3
but found 2
at Assert.That(2).IsEqualTo(3)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(exception3.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 4
but found 3
at Assert.That(3).IsEqualTo(4)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(exception4.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 5
but found 4
at Assert.That(4).IsEqualTo(5)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(exception5.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 6
but found 5
at Assert.That(5).IsEqualTo(6)
- """);
+ """.NormalizeLineEndings());
}
[Test]
@@ -93,7 +93,7 @@ or to be 3
but found 1
at Assert.That(1).IsEqualTo(2).Or.IsEqualTo(3)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(exception2.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 3
@@ -101,7 +101,7 @@ and to be 4
but found 2
at Assert.That(2).IsEqualTo(3).And.IsEqualTo(4)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(exception3.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 4
@@ -109,7 +109,7 @@ or to be 5
but found 3
at Assert.That(3).IsEqualTo(4).Or.IsEqualTo(5)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(exception4.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 5
@@ -117,7 +117,7 @@ and to be 6
but found 4
at Assert.That(4).IsEqualTo(5).And.IsEqualTo(6)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(exception5.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 6
@@ -125,7 +125,7 @@ or to be 7
but found 5
at Assert.That(5).IsEqualTo(6).Or.IsEqualTo(7)
- """);
+ """.NormalizeLineEndings());
}
[Test]
@@ -176,48 +176,48 @@ Expected to be 2
but found 1
at Assert.That(1).IsEqualTo(2)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(assertionException2.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 3
but found 2
at Assert.That(2).IsEqualTo(3)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(assertionException3.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 4
but found 3
at Assert.That(3).IsEqualTo(4)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(assertionException4.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 5
but found 4
at Assert.That(4).IsEqualTo(5)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(assertionException5.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 6
but found 5
at Assert.That(5).IsEqualTo(6)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(assertionException6.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 7
but found 6
at Assert.That(6).IsEqualTo(7)
- """);
+ """.NormalizeLineEndings());
await TUnitAssert.That(assertionException7.Message.NormalizeLineEndings()).IsEqualTo("""
Expected to be 8
but found 7
at Assert.That(7).IsEqualTo(8)
- """);
+ """.NormalizeLineEndings());
}
}
diff --git a/TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs b/TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs
index 841660fe8d..a420ea73d1 100644
--- a/TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs
+++ b/TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs
@@ -136,7 +136,7 @@ await TUnitAssert.That(exception!.Message.NormalizeLineEndings()).IsEqualTo(
but collection item at index 1 does not match: expected 2, but was 5
at Assert.That(array).IsEquivalentTo(list, CollectionOrdering.Matching)
- """
+ """.NormalizeLineEndings()
);
}
@@ -155,7 +155,7 @@ await TUnitAssert.That(exception!.Message.NormalizeLineEndings()).IsEqualTo(
but collection item at index 1 does not match: expected 2, but was 5
at Assert.That(array).IsEquivalentTo(list, CollectionOrdering.Matching)
- """
+ """.NormalizeLineEndings()
);
}
diff --git a/TUnit.Assertions.Tests/Old/StringRegexAssertionTests.cs b/TUnit.Assertions.Tests/Old/StringRegexAssertionTests.cs
index 3c1bffb616..98b2bcae9f 100644
--- a/TUnit.Assertions.Tests/Old/StringRegexAssertionTests.cs
+++ b/TUnit.Assertions.Tests/Old/StringRegexAssertionTests.cs
@@ -56,13 +56,13 @@ public async Task Matches_WithInvalidPattern_StringPattern_Throws(Type exception
return;
}
- await TUnitAssert.That(exception!.Message).IsEqualTo(
+ await TUnitAssert.That(exception!.Message.NormalizeLineEndings()).IsEqualTo(
$"""
Expected text match pattern
but The regex "^\d+$" does not match with "{text}"
at Assert.That(text).Matches(pattern)
- """
+ """.NormalizeLineEndings()
);
}
@@ -81,13 +81,13 @@ public async Task Matches_WithInvalidPattern_RegexPattern_Throws(Type exceptionT
return;
}
- await TUnitAssert.That(exception!.Message).IsEqualTo(
+ await TUnitAssert.That(exception!.Message.NormalizeLineEndings()).IsEqualTo(
$"""
Expected text match pattern
but The regex "^\d+$" does not match with "{text}"
at Assert.That(text).Matches(pattern)
- """
+ """.NormalizeLineEndings()
);
}
@@ -110,13 +110,13 @@ public async Task Matches_WithInvalidPattern_GeneratedRegexPattern_Throws(Type e
return;
}
- await TUnitAssert.That(exception!.Message).IsEqualTo(
+ await TUnitAssert.That(exception!.Message.NormalizeLineEndings()).IsEqualTo(
$"""
Expected text match regex
but The regex "^\d+$" does not match with "Hello123World"
at Assert.That(text).Matches(regex)
- """
+ """.NormalizeLineEndings()
);
}
#endif
@@ -192,13 +192,13 @@ public async Task DoesNotMatch_WithInvalidPattern_StringPattern_Throws(Type exce
return;
}
- await TUnitAssert.That(exception!.Message).IsEqualTo(
+ await TUnitAssert.That(exception!.Message.NormalizeLineEndings()).IsEqualTo(
$"""
Expected text to not match with pattern
but The regex "^\d+$" matches with "{text}"
at Assert.That(text).DoesNotMatch(pattern)
- """
+ """.NormalizeLineEndings()
);
}
@@ -217,13 +217,13 @@ public async Task DoesNotMatch_WithInvalidPattern_RegexPattern_Throws(Type excep
return;
}
- await TUnitAssert.That(exception!.Message).IsEqualTo(
+ await TUnitAssert.That(exception!.Message.NormalizeLineEndings()).IsEqualTo(
$"""
Expected text to not match with pattern
but The regex "^\d+$" matches with "{text}"
at Assert.That(text).DoesNotMatch(pattern)
- """
+ """.NormalizeLineEndings()
);
}
@@ -246,13 +246,13 @@ public async Task DoesNotMatch_WithInvalidPattern_GeneratedRegexPattern_Throws(T
return;
}
- await TUnitAssert.That(exception!.Message).IsEqualTo(
+ await TUnitAssert.That(exception!.Message.NormalizeLineEndings()).IsEqualTo(
$"""
Expected text to not match with regex
but The regex "^\d+$" matches with "{text}"
at Assert.That(text).DoesNotMatch(regex)
- """
+ """.NormalizeLineEndings()
);
}
#endif
diff --git a/TUnit.Assertions.Tests/StringLengthAssertionTests.cs b/TUnit.Assertions.Tests/StringLengthAssertionTests.cs
new file mode 100644
index 0000000000..87fd7e2ae2
--- /dev/null
+++ b/TUnit.Assertions.Tests/StringLengthAssertionTests.cs
@@ -0,0 +1,164 @@
+using TUnit.Assertions.Exceptions;
+
+namespace TUnit.Assertions.Tests;
+
+public class StringLengthAssertionTests
+{
+ [Test]
+ public async Task Length_IsEqualTo_Passes_When_Length_Matches()
+ {
+ var str = "Hello";
+ await Assert.That(str).Length().IsEqualTo(5);
+ }
+
+ [Test]
+ public async Task Length_IsEqualTo_Fails_When_Length_DoesNotMatch()
+ {
+ var str = "Hello";
+ await Assert.That(async () => await Assert.That(str).Length().IsEqualTo(10))
+ .Throws()
+ .And.HasMessageContaining("10");
+ }
+
+ [Test]
+ public async Task Length_IsGreaterThan_Passes_When_Length_IsGreater()
+ {
+ var str = "Hello, World!";
+ await Assert.That(str).Length().IsGreaterThan(5);
+ }
+
+ [Test]
+ public async Task Length_IsGreaterThan_Fails_When_Length_IsNotGreater()
+ {
+ var str = "Hi";
+ await Assert.That(async () => await Assert.That(str).Length().IsGreaterThan(5))
+ .Throws()
+ .And.HasMessageContaining("greater than")
+ .And.HasMessageContaining("5");
+ }
+
+ [Test]
+ public async Task Length_IsLessThan_Passes_When_Length_IsLess()
+ {
+ var str = "Hi";
+ await Assert.That(str).Length().IsLessThan(10);
+ }
+
+ [Test]
+ public async Task Length_IsLessThan_Fails_When_Length_IsNotLess()
+ {
+ var str = "Hello, World!";
+ await Assert.That(async () => await Assert.That(str).Length().IsLessThan(5))
+ .Throws()
+ .And.HasMessageContaining("less than")
+ .And.HasMessageContaining("5");
+ }
+
+ [Test]
+ public async Task Length_IsGreaterThanOrEqualTo_Passes_When_Equal()
+ {
+ var str = "Hello";
+ await Assert.That(str).Length().IsGreaterThanOrEqualTo(5);
+ }
+
+ [Test]
+ public async Task Length_IsGreaterThanOrEqualTo_Passes_When_Greater()
+ {
+ var str = "Hello, World!";
+ await Assert.That(str).Length().IsGreaterThanOrEqualTo(5);
+ }
+
+ [Test]
+ public async Task Length_IsGreaterThanOrEqualTo_Fails_When_Less()
+ {
+ var str = "Hi";
+ await Assert.That(async () => await Assert.That(str).Length().IsGreaterThanOrEqualTo(5))
+ .Throws()
+ .And.HasMessageContaining("greater than or equal to")
+ .And.HasMessageContaining("5");
+ }
+
+ [Test]
+ public async Task Length_IsLessThanOrEqualTo_Passes_When_Equal()
+ {
+ var str = "Hello";
+ await Assert.That(str).Length().IsLessThanOrEqualTo(5);
+ }
+
+ [Test]
+ public async Task Length_IsLessThanOrEqualTo_Passes_When_Less()
+ {
+ var str = "Hi";
+ await Assert.That(str).Length().IsLessThanOrEqualTo(5);
+ }
+
+ [Test]
+ public async Task Length_IsLessThanOrEqualTo_Fails_When_Greater()
+ {
+ var str = "Hello, World!";
+ await Assert.That(async () => await Assert.That(str).Length().IsLessThanOrEqualTo(5))
+ .Throws()
+ .And.HasMessageContaining("less than or equal to")
+ .And.HasMessageContaining("5");
+ }
+
+ [Test]
+ public async Task Length_IsBetween_Passes_When_InRange()
+ {
+ var str = "Hello";
+ await Assert.That(str).Length().IsBetween(1, 10);
+ }
+
+ [Test]
+ public async Task Length_IsBetween_Fails_When_OutOfRange()
+ {
+ var str = "Hello, World!";
+ await Assert.That(async () => await Assert.That(str).Length().IsBetween(1, 5))
+ .Throws()
+ .And.HasMessageContaining("between");
+ }
+
+ [Test]
+ public async Task Length_IsPositive_Passes_For_NonEmptyString()
+ {
+ var str = "Hello";
+ await Assert.That(str).Length().IsPositive();
+ }
+
+ [Test]
+ public async Task Length_IsGreaterThanOrEqualTo_Zero_Passes_Always()
+ {
+ var str = "";
+ await Assert.That(str).Length().IsGreaterThanOrEqualTo(0);
+ }
+
+ [Test]
+ public async Task Length_IsZero_Passes_For_EmptyString()
+ {
+ var str = "";
+ await Assert.That(str).Length().IsZero();
+ }
+
+ [Test]
+ public async Task Length_IsNotZero_Passes_For_NonEmptyString()
+ {
+ var str = "Hello";
+ await Assert.That(str).Length().IsNotZero();
+ }
+
+ [Test]
+ public async Task Length_WithNullString_Returns_Zero()
+ {
+ string? str = null;
+ await Assert.That(str).Length().IsZero();
+ }
+
+ [Test]
+ public async Task Length_Chained_With_And()
+ {
+ var str = "Hello";
+ await Assert.That(str)
+ .Length().IsGreaterThan(3)
+ .And.IsLessThan(10);
+ }
+}
diff --git a/TUnit.Assertions.Tests/ThrowInDelegateValueAssertionTests.cs b/TUnit.Assertions.Tests/ThrowInDelegateValueAssertionTests.cs
index 86cec759e2..c776362562 100644
--- a/TUnit.Assertions.Tests/ThrowInDelegateValueAssertionTests.cs
+++ b/TUnit.Assertions.Tests/ThrowInDelegateValueAssertionTests.cs
@@ -5,18 +5,19 @@ public class ThrowInDelegateValueAssertionTests
[Test]
public async Task ThrowInDelegateValueAssertion_ReturnsExpectedErrorMessage()
{
+ var expectedContains = """
+ Expected to be equal to True
+ but threw System.Exception
+ """.NormalizeLineEndings();
var assertion = async () => await Assert.That(() =>
{
throw new Exception("No");
return true;
}).IsEqualTo(true);
- await Assert.That(assertion)
- .Throws()
- .WithMessageContaining("""
- Expected to be equal to True
- but threw System.Exception
- """);
+ var exception = await Assert.That(assertion)
+ .Throws();
+ await Assert.That(exception.Message.NormalizeLineEndings()).Contains(expectedContains);
}
[Test]
diff --git a/TUnit.Assertions/Chaining/AndAssertion.cs b/TUnit.Assertions/Chaining/AndAssertion.cs
index b9186e64ee..486c947b73 100644
--- a/TUnit.Assertions/Chaining/AndAssertion.cs
+++ b/TUnit.Assertions/Chaining/AndAssertion.cs
@@ -139,10 +139,10 @@ private string BuildCombinedExpectation()
var becausePrefix = firstBecause.StartsWith("because ", StringComparison.OrdinalIgnoreCase)
? firstBecause
: $"because {firstBecause}";
- return $"{firstExpectation}, {becausePrefix}{Environment.NewLine}and {secondExpectation}";
+ return $"{firstExpectation}, {becausePrefix}\nand {secondExpectation}";
}
- return $"{firstExpectation}{Environment.NewLine}and {secondExpectation}";
+ return $"{firstExpectation}\nand {secondExpectation}";
}
protected override string GetExpectation() => "both conditions";
diff --git a/TUnit.Assertions/Chaining/OrAssertion.cs b/TUnit.Assertions/Chaining/OrAssertion.cs
index 47aa2e87c2..15b042a531 100644
--- a/TUnit.Assertions/Chaining/OrAssertion.cs
+++ b/TUnit.Assertions/Chaining/OrAssertion.cs
@@ -140,10 +140,10 @@ private string BuildCombinedExpectation()
var becausePrefix = firstBecause.StartsWith("because ", StringComparison.OrdinalIgnoreCase)
? firstBecause
: $"because {firstBecause}";
- return $"{firstExpectation}, {becausePrefix}{Environment.NewLine}or {secondExpectation}";
+ return $"{firstExpectation}, {becausePrefix}\nor {secondExpectation}";
}
- return $"{firstExpectation}{Environment.NewLine}or {secondExpectation}";
+ return $"{firstExpectation}\nor {secondExpectation}";
}
protected override string GetExpectation() => "either condition";
diff --git a/TUnit.Assertions/Conditions/CollectionCountSource.cs b/TUnit.Assertions/Conditions/CollectionCountSource.cs
new file mode 100644
index 0000000000..4ffd07fd24
--- /dev/null
+++ b/TUnit.Assertions/Conditions/CollectionCountSource.cs
@@ -0,0 +1,322 @@
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Sources;
+
+namespace TUnit.Assertions.Conditions;
+
+///
+/// Provides count assertions that preserve collection type for further chaining.
+/// This enables patterns like: Assert.That(list).Count(item => item.IsGreaterThan(3)).IsEqualTo(2).And.Contains(5)
+///
+public class CollectionCountSource
+ where TCollection : IEnumerable
+{
+ private readonly AssertionContext _collectionContext;
+ private readonly Func, Assertion?>? _assertion;
+
+ public CollectionCountSource(
+ AssertionContext collectionContext,
+ Func, Assertion?>? assertion)
+ {
+ _collectionContext = collectionContext;
+ _assertion = assertion;
+ }
+
+ ///
+ /// Asserts that the count is equal to the expected value.
+ /// Returns a collection-aware assertion that allows further collection chaining.
+ ///
+ public CollectionCountEqualsAssertion IsEqualTo(
+ int expected,
+ [CallerArgumentExpression(nameof(expected))] string? expression = null)
+ {
+ _collectionContext.ExpressionBuilder.Append($".IsEqualTo({expression})");
+ return new CollectionCountEqualsAssertion(
+ _collectionContext, _assertion, expected, CountComparison.Equal);
+ }
+
+ ///
+ /// Asserts that the count is not equal to the expected value.
+ /// Returns a collection-aware assertion that allows further collection chaining.
+ ///
+ public CollectionCountEqualsAssertion IsNotEqualTo(
+ int expected,
+ [CallerArgumentExpression(nameof(expected))] string? expression = null)
+ {
+ _collectionContext.ExpressionBuilder.Append($".IsNotEqualTo({expression})");
+ return new CollectionCountEqualsAssertion(
+ _collectionContext, _assertion, expected, CountComparison.NotEqual);
+ }
+
+ ///
+ /// Asserts that the count is greater than the expected value.
+ /// Returns a collection-aware assertion that allows further collection chaining.
+ ///
+ public CollectionCountEqualsAssertion IsGreaterThan(
+ int expected,
+ [CallerArgumentExpression(nameof(expected))] string? expression = null)
+ {
+ _collectionContext.ExpressionBuilder.Append($".IsGreaterThan({expression})");
+ return new CollectionCountEqualsAssertion(
+ _collectionContext, _assertion, expected, CountComparison.GreaterThan);
+ }
+
+ ///
+ /// Asserts that the count is greater than or equal to the expected value.
+ /// Returns a collection-aware assertion that allows further collection chaining.
+ ///
+ public CollectionCountEqualsAssertion IsGreaterThanOrEqualTo(
+ int expected,
+ [CallerArgumentExpression(nameof(expected))] string? expression = null)
+ {
+ _collectionContext.ExpressionBuilder.Append($".IsGreaterThanOrEqualTo({expression})");
+ return new CollectionCountEqualsAssertion(
+ _collectionContext, _assertion, expected, CountComparison.GreaterThanOrEqual);
+ }
+
+ ///
+ /// Asserts that the count is less than the expected value.
+ /// Returns a collection-aware assertion that allows further collection chaining.
+ ///
+ public CollectionCountEqualsAssertion IsLessThan(
+ int expected,
+ [CallerArgumentExpression(nameof(expected))] string? expression = null)
+ {
+ _collectionContext.ExpressionBuilder.Append($".IsLessThan({expression})");
+ return new CollectionCountEqualsAssertion(
+ _collectionContext, _assertion, expected, CountComparison.LessThan);
+ }
+
+ ///
+ /// Asserts that the count is less than or equal to the expected value.
+ /// Returns a collection-aware assertion that allows further collection chaining.
+ ///
+ public CollectionCountEqualsAssertion IsLessThanOrEqualTo(
+ int expected,
+ [CallerArgumentExpression(nameof(expected))] string? expression = null)
+ {
+ _collectionContext.ExpressionBuilder.Append($".IsLessThanOrEqualTo({expression})");
+ return new CollectionCountEqualsAssertion(
+ _collectionContext, _assertion, expected, CountComparison.LessThanOrEqual);
+ }
+
+ ///
+ /// Asserts that the count is zero.
+ /// Returns a collection-aware assertion that allows further collection chaining.
+ ///
+ public CollectionCountEqualsAssertion IsZero()
+ {
+ _collectionContext.ExpressionBuilder.Append(".IsZero()");
+ return new CollectionCountEqualsAssertion(
+ _collectionContext, _assertion, 0, CountComparison.Equal);
+ }
+
+ ///
+ /// Asserts that the count is positive (greater than zero).
+ /// Returns a collection-aware assertion that allows further collection chaining.
+ ///
+ public CollectionCountEqualsAssertion IsPositive()
+ {
+ _collectionContext.ExpressionBuilder.Append(".IsPositive()");
+ return new CollectionCountEqualsAssertion(
+ _collectionContext, _assertion, 0, CountComparison.GreaterThan);
+ }
+}
+
+internal enum CountComparison
+{
+ Equal,
+ NotEqual,
+ GreaterThan,
+ GreaterThanOrEqual,
+ LessThan,
+ LessThanOrEqual
+}
+
+///
+/// Collection-aware count assertion that preserves the collection type for further chaining.
+/// Inherits from CollectionAssertionBase to enable .And.Contains(), .And.IsNotEmpty(), etc.
+///
+public class CollectionCountEqualsAssertion : CollectionAssertionBase
+ where TCollection : IEnumerable
+{
+ private readonly Func, Assertion?>? _itemAssertion;
+ private readonly int _expected;
+ private readonly CountComparison _comparison;
+ private int _actualCount;
+
+ internal CollectionCountEqualsAssertion(
+ AssertionContext context,
+ Func, Assertion?>? itemAssertion,
+ int expected,
+ CountComparison comparison)
+ : base(context)
+ {
+ _itemAssertion = itemAssertion;
+ _expected = expected;
+ _comparison = comparison;
+ }
+
+ protected override async Task CheckAsync(EvaluationMetadata metadata)
+ {
+ var value = metadata.Value;
+ var exception = metadata.Exception;
+
+ if (exception != null)
+ {
+ return AssertionResult.Failed($"threw {exception.GetType().Name}");
+ }
+
+ if (value == null)
+ {
+ return AssertionResult.Failed("collection was null");
+ }
+
+ // Calculate count
+ if (_itemAssertion == null)
+ {
+ // Simple count without filtering
+ _actualCount = value switch
+ {
+ System.Collections.ICollection c => c.Count,
+ _ => System.Linq.Enumerable.Count(value)
+ };
+ }
+ else
+ {
+ // Count items that satisfy the inner assertion
+ _actualCount = 0;
+ int index = 0;
+
+ foreach (var item in value)
+ {
+ var itemAssertionSource = new ValueAssertion(item, $"item[{index}]");
+ var resultingAssertion = _itemAssertion(itemAssertionSource);
+
+ if (resultingAssertion != null)
+ {
+ try
+ {
+ await resultingAssertion.AssertAsync();
+ _actualCount++;
+ }
+ catch
+ {
+ // Item did not satisfy the assertion, don't count it
+ }
+ }
+ else
+ {
+ // Null assertion means no constraint, count all items
+ _actualCount++;
+ }
+
+ index++;
+ }
+ }
+
+ // Check the comparison
+ var passed = _comparison switch
+ {
+ CountComparison.Equal => _actualCount == _expected,
+ CountComparison.NotEqual => _actualCount != _expected,
+ CountComparison.GreaterThan => _actualCount > _expected,
+ CountComparison.GreaterThanOrEqual => _actualCount >= _expected,
+ CountComparison.LessThan => _actualCount < _expected,
+ CountComparison.LessThanOrEqual => _actualCount <= _expected,
+ _ => false
+ };
+
+ if (passed)
+ {
+ return AssertionResult.Passed;
+ }
+
+ return AssertionResult.Failed($"found {_actualCount}");
+ }
+
+ protected override string GetExpectation()
+ {
+ var comparisonText = _comparison switch
+ {
+ CountComparison.Equal => $"to have count equal to {_expected}",
+ CountComparison.NotEqual => $"to have count not equal to {_expected}",
+ CountComparison.GreaterThan => $"to have count greater than {_expected}",
+ CountComparison.GreaterThanOrEqual => $"to have count greater than or equal to {_expected}",
+ CountComparison.LessThan => $"to have count less than {_expected}",
+ CountComparison.LessThanOrEqual => $"to have count less than or equal to {_expected}",
+ _ => $"to have count {_expected}"
+ };
+
+ return comparisonText;
+ }
+}
+
+///
+/// Collection-aware count assertion that executes an inline count assertion lambda.
+/// Preserves the collection type for further chaining.
+/// Example: Assert.That(list).Count(c => c.IsEqualTo(5)).And.Contains(1)
+///
+public class CollectionCountWithInlineAssertionAssertion : CollectionAssertionBase
+ where TCollection : IEnumerable
+{
+ private readonly Func, Assertion?> _countAssertion;
+ private int _actualCount;
+
+ internal CollectionCountWithInlineAssertionAssertion(
+ AssertionContext context,
+ Func, Assertion?> countAssertion)
+ : base(context)
+ {
+ _countAssertion = countAssertion;
+ }
+
+ protected override async Task CheckAsync(EvaluationMetadata metadata)
+ {
+ var value = metadata.Value;
+ var exception = metadata.Exception;
+
+ if (exception != null)
+ {
+ return AssertionResult.Failed($"threw {exception.GetType().Name}");
+ }
+
+ if (value == null)
+ {
+ return AssertionResult.Failed("collection was null");
+ }
+
+ // Calculate count
+ _actualCount = value switch
+ {
+ System.Collections.ICollection c => c.Count,
+ _ => System.Linq.Enumerable.Count(value)
+ };
+
+ // Create an assertion source for the count and run the inline assertion
+ var countSource = new ValueAssertion(_actualCount, "count");
+ var resultingAssertion = _countAssertion(countSource);
+
+ if (resultingAssertion != null)
+ {
+ try
+ {
+ await resultingAssertion.AssertAsync();
+ return AssertionResult.Passed;
+ }
+ catch
+ {
+ // Count assertion failed
+ return AssertionResult.Failed($"count was {_actualCount}");
+ }
+ }
+
+ // Null assertion means no constraint, always pass
+ return AssertionResult.Passed;
+ }
+
+ protected override string GetExpectation()
+ {
+ return "to satisfy count assertion";
+ }
+}
diff --git a/TUnit.Assertions/Conditions/CollectionCountValueAssertion.cs b/TUnit.Assertions/Conditions/CollectionCountValueAssertion.cs
deleted file mode 100644
index 2b1c5a9670..0000000000
--- a/TUnit.Assertions/Conditions/CollectionCountValueAssertion.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System.Collections;
-using TUnit.Assertions.Core;
-
-namespace TUnit.Assertions.Conditions;
-
-///
-/// Assertion that evaluates the count of a collection and provides numeric assertions on that count.
-/// Implements IAssertionSource<int> to enable all numeric assertion methods.
-///
-public class CollectionCountValueAssertion : Sources.ValueAssertion
- where TCollection : IEnumerable
-{
- public CollectionCountValueAssertion(
- AssertionContext collectionContext,
- Func? predicate)
- : base(CreateIntContext(collectionContext, predicate))
- {
- }
-
- private static AssertionContext CreateIntContext(
- AssertionContext collectionContext,
- Func? predicate)
- {
- return collectionContext.Map(collection =>
- {
- if (collection == null)
- {
- return 0;
- }
-
- // Calculate count efficiently
- if (predicate == null)
- {
- return collection switch
- {
- ICollection c => c.Count,
- _ => System.Linq.Enumerable.Count(collection)
- };
- }
-
- return System.Linq.Enumerable.Count(collection, predicate);
- });
- }
-}
diff --git a/TUnit.Assertions/Conditions/EqualsAssertion.cs b/TUnit.Assertions/Conditions/EqualsAssertion.cs
index 821a2b4e7f..350c439fad 100644
--- a/TUnit.Assertions/Conditions/EqualsAssertion.cs
+++ b/TUnit.Assertions/Conditions/EqualsAssertion.cs
@@ -3,6 +3,7 @@
using System.Reflection;
using System.Text;
using TUnit.Assertions.Attributes;
+using TUnit.Assertions.Conditions.Helpers;
using TUnit.Assertions.Core;
namespace TUnit.Assertions.Conditions;
@@ -84,7 +85,7 @@ protected override Task CheckAsync(EvaluationMetadata m
if (_ignoredTypes.Count > 0)
{
// Use reference-based tracking to detect cycles
- var visited = new HashSet