Master xUnit testing patterns for ABP Framework applications including unit tests, integration tests, test data seeders, and mocking strategies...
Comprehensive testing patterns for ABP Framework applications using xUnit, Shouldly, and NSubstitute.
{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
Write tests against interfaces before implementation exists. This enables parallel development in /add-feature workflow.
abp-developer and qa-engineer// 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);
}
}
// {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";
}
// {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);
}
}
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 normal successful operations.
[Fact]
public async Task Should_Create_Entity_Successfully()
{
// Standard create with valid data
}
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));
}
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));
});
}
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
}
See references/appservice-test-template.md for full lifecycle test examples.
// 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"
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.
// 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");
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");
}
For each AppService, verify:
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 |