| name | xunit-testing-patterns |
| description | Master xUnit testing patterns for ABP Framework applications including unit tests, integration tests, test data seeders, and mocking strategies. Use when: (1) writing xUnit tests for ABP services, (2) creating test data seeders, (3) implementing integration tests, (4) setting up test infrastructure. |
| layer | 3 |
| tech_stack | dotnet, csharp, xunit |
| topics | unit-testing, integration-testing, mocking, test-data, shouldly, nsubstitute, interface-first |
| depends_on | abp-framework-patterns |
| complements | e2e-testing-patterns |
| keywords | xUnit, Fact, Theory, Shouldly, NSubstitute, TestBase, DataSeeder, InlineData, Interface-First |
xUnit Testing Patterns for ABP Framework
Comprehensive testing patterns for ABP Framework applications using xUnit, Shouldly, and NSubstitute.
When to Use
- Writing unit tests for AppServices
- Creating integration tests for ABP modules
- Setting up test data seeders
- Mocking repositories and services
- Testing authorization and validation
- Writing domain service tests
- Interface-first testing (writing tests before implementation)
Test Project Structure
{ProjectName}.TestBase/
├── {ProjectName}TestBase.cs # Base class with common setup
├── {ProjectName}TestBaseModule.cs # Test module configuration
└── {Feature}/
├── {Entity}TestData.cs # Test constants
└── {Entity}TestDataSeedContributor.cs # Test data seeder
{ProjectName}.Application.Tests/
├── {ProjectName}ApplicationTestBase.cs # Application test base
├── {ProjectName}ApplicationTestModule.cs
└── {Feature}/
└── {Entity}AppService_Tests.cs # AppService tests
{ProjectName}.Domain.Tests/
├── {ProjectName}DomainTestBase.cs # Domain test base
├── {ProjectName}DomainTestModule.cs
└── {Feature}/
└── {Entity}Manager_Tests.cs # Domain service tests
Interface-First Testing (NEW)
Write tests against interfaces before implementation exists. This enables parallel development in /add-feature workflow.
Benefits
- Tests can be written as soon as interface contracts exist
- Enables true parallel execution of
abp-developerandqa-engineer - Tests document expected behavior
- Catches interface design issues early
Example: Testing Against Interface
// This test compiles and is ready to run once implementation exists
public class PatientAppService_Tests : ClinicApplicationTestBase
{
private readonly IPatientAppService _patientAppService;
public PatientAppService_Tests()
{
// Resolves implementation from DI container
_patientAppService = GetRequiredService<IPatientAppService>();
}
[Fact]
public async Task GetAsync_WithValidId_ReturnsPatient()
{
// Arrange - uses test data constants
var patientId = PatientTestData.Patient1Id;
// Act - calls interface method
var result = await _patientAppService.GetAsync(patientId);
// Assert - validates contract expectations
result.ShouldNotBeNull();
result.Id.ShouldBe(patientId);
result.FirstName.ShouldBe(PatientTestData.Patient1FirstName);
}
}
Core Templates
Test Data Constants
// {ProjectName}.TestBase/{Feature}/{Entity}TestData.cs
namespace {ProjectName}.{Feature};
public static class PatientTestData
{
// Use deterministic GUIDs for test reproducibility
public static Guid Patient1Id { get; } = Guid.Parse("00000000-0000-0000-0001-000000000001");
public static Guid Patient2Id { get; } = Guid.Parse("00000000-0000-0000-0001-000000000002");
public static Guid NonExistentId { get; } = Guid.Parse("00000000-0000-0000-0001-999999999999");
// Valid test data
public const string Patient1FirstName = "John";
public const string Patient1LastName = "Doe";
public const string Patient1Email = "john.doe@example.com";
public const string Patient2FirstName = "Jane";
public const string Patient2LastName = "Smith";
public const string Patient2Email = "jane.smith@example.com";
// Valid data for create tests
public const string ValidFirstName = "New";
public const string ValidLastName = "Patient";
public const string ValidEmail = "new.patient@example.com";
// Invalid data for negative tests
public const string EmptyString = "";
public const string WhitespaceString = " ";
public static readonly string TooLongName = new('X', 256);
public const string InvalidEmail = "not-an-email";
}
Test Data Seeder
// {ProjectName}.TestBase/{Feature}/{Entity}TestDataSeedContributor.cs
using System;
using System.Threading.Tasks;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
namespace {ProjectName}.{Feature};
public class PatientTestDataSeedContributor : IDataSeedContributor, ITransientDependency
{
private readonly IRepository<Patient, Guid> _repository;
public PatientTestDataSeedContributor(IRepository<Patient, Guid> repository)
{
_repository = repository;
}
public async Task SeedAsync(DataSeedContext context)
{
// Idempotent seeding
if (await _repository.GetCountAsync() > 0)
{
return;
}
// Patient 1 - Active
await _repository.InsertAsync(
new Patient(
PatientTestData.Patient1Id,
PatientTestData.Patient1FirstName,
PatientTestData.Patient1LastName,
PatientTestData.Patient1Email),
autoSave: true);
// Patient 2 - For deletion/update tests
await _repository.InsertAsync(
new Patient(
PatientTestData.Patient2Id,
PatientTestData.Patient2FirstName,
PatientTestData.Patient2LastName,
PatientTestData.Patient2Email),
autoSave: true);
}
}
AppService Test Class
For full test class template with all CRUD operations, lifecycle tests, and mocking patterns: See references/appservice-test-template.md
Quick example:
[Trait("Category", "Integration")]
public class {Entity}AppService_Tests : {ProjectName}ApplicationTestBase
{
private readonly I{Entity}AppService _{entity}AppService;
public {Entity}AppService_Tests()
{
_{entity}AppService = GetRequiredService<I{Entity}AppService>();
}
[Fact]
public async Task GetAsync_WithValidId_Returns{Entity}()
{
var result = await _{entity}AppService.GetAsync({Entity}TestData.{Entity}1Id);
result.ShouldNotBeNull();
result.Id.ShouldBe({Entity}TestData.{Entity}1Id);
}
[Fact]
public async Task CreateAsync_WithValidInput_CreatesAndReturns{Entity}()
{
var input = new Create{Entity}Dto { Name = {Entity}TestData.ValidName };
var result = await _{entity}AppService.CreateAsync(input);
result.ShouldNotBeNull();
result.Id.ShouldNotBe(Guid.Empty);
}
}
Test Categories
1. Happy Path Tests
Test normal successful operations.
[Fact]
public async Task Should_Create_Entity_Successfully()
{
// Standard create with valid data
}
2. Validation Tests
Test input validation and constraints.
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData(" ")]
public async Task Should_Reject_Invalid_Name(string? name)
{
var input = new CreateDto { Name = name! };
await Should.ThrowAsync<AbpValidationException>(
() => _service.CreateAsync(input));
}
3. Authorization Tests
Test permission enforcement.
[Fact]
public async Task Should_Require_Permission_To_Create()
{
// Login as user without permission
await WithUnitOfWorkAsync(async () =>
{
await Should.ThrowAsync<AbpAuthorizationException>(
() => _service.CreateAsync(input));
});
}
4. Edge Case Tests
Test boundary conditions and edge cases.
[Fact]
public async Task Should_Handle_Empty_List()
{
// Clear all data
var result = await _service.GetListAsync(new GetListInput());
result.TotalCount.ShouldBe(0);
result.Items.ShouldBeEmpty();
}
[Fact]
public async Task Should_Handle_Max_Page_Size()
{
var result = await _service.GetListAsync(
new GetListInput { MaxResultCount = 1000 });
result.Items.Count.ShouldBeLessThanOrEqualTo(100); // Capped
}
5. Lifecycle Tests (for Activate/Deactivate patterns)
See references/appservice-test-template.md for full lifecycle test examples.
Test Traits for Organization
// Categorize tests for selective execution
[Trait("Category", "Unit")]
[Trait("Feature", "Patients")]
public class PatientAppService_UnitTests { }
[Trait("Category", "Integration")]
[Trait("Feature", "Patients")]
public class PatientAppService_IntegrationTests { }
// Run by category:
// dotnet test --filter "Category=Unit"
// dotnet test --filter "Feature=Patients"
Mocking with NSubstitute
using NSubstitute;
// Create mock
var repository = Substitute.For<IRepository<{Entity}, Guid>>();
// Setup return value
repository.GetAsync(entityId).Returns(entity);
// Verify call
await repository.Received(1).GetAsync(entityId);
For full mocking examples, see references/appservice-test-template.md.
Shouldly Assertion Patterns
// Null checks
result.ShouldNotBeNull();
result.ShouldBeNull();
// Equality
result.Id.ShouldBe(expectedId);
result.Name.ShouldNotBe(oldName);
// Collections
result.Items.ShouldNotBeEmpty();
result.Items.ShouldContain(x => x.Name == "Test");
result.Items.Count.ShouldBe(5);
result.Items.ShouldAllBe(x => x.IsActive);
// Numeric comparisons
result.TotalCount.ShouldBeGreaterThan(0);
result.TotalCount.ShouldBeLessThanOrEqualTo(100);
result.TotalCount.ShouldBeInRange(1, 100);
// String assertions
result.Name.ShouldStartWith("Test");
result.Email.ShouldContain("@");
result.Name.ShouldNotBeNullOrWhiteSpace();
// Boolean assertions
result.IsActive.ShouldBeTrue();
result.IsDeleted.ShouldBeFalse();
// Exception assertions
await Should.ThrowAsync<EntityNotFoundException>(
async () => await _service.GetAsync(invalidId));
var ex = await Should.ThrowAsync<BusinessException>(
async () => await _service.CreateAsync(input));
ex.Code.ShouldBe("DuplicateEmail");
Parallel Test Safety
When tests run in parallel, ensure data isolation:
// Use unique IDs per test class
public static class PatientTestData
{
// Include feature identifier in GUIDs to avoid collisions
private const string FeaturePrefix = "00000000-0000-0001";
public static Guid Patient1Id { get; } = Guid.Parse($"{FeaturePrefix}-0001-000000000001");
public static Guid Patient2Id { get; } = Guid.Parse($"{FeaturePrefix}-0001-000000000002");
}
Test Checklist
For each AppService, verify:
- GetAsync - valid ID returns entity
- GetAsync - non-existent ID throws EntityNotFoundException
- GetListAsync - returns paginated results
- GetListAsync - respects filters
- GetListAsync - respects pagination
- CreateAsync - valid input creates entity
- CreateAsync - empty required field throws validation
- CreateAsync - exceeds max length throws validation
- UpdateAsync - valid input updates entity
- UpdateAsync - non-existent ID throws EntityNotFoundException
- DeleteAsync - valid ID deletes entity
- DeleteAsync - non-existent ID throws EntityNotFoundException
- (If applicable) ActivateAsync - activates inactive entity
- (If applicable) DeactivateAsync - deactivates active entity
Shared Knowledge
For foundational patterns, see the shared knowledge base:
| Topic | File | Description |
|---|---|---|
| Folder structure | knowledge/conventions/folder-structure.md | Test project layout |
| Naming conventions | knowledge/conventions/naming.md | Test class naming |
| CRUD example | knowledge/examples/crud-entity.md | Test target example |
References
- references/integration-test-patterns.md - Advanced integration testing
- references/test-fixtures.md - Shared test fixtures