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-developer and qa-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
- Happy Path Tests
Test normal successful operations.
[Fact] public async Task Should_Create_Entity_Successfully() { // Standard create with valid data }
- 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)); }
- 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)); }); }
- 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 }
- 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