AAA Pattern — Arrange, Act, Assert for Clean, Readable Tests
How to apply the Arrange-Act-Assert pattern consistently in Clean Architecture .NET tests: structure, naming conventions, FluentAssertions, parameterized tests with Theory, and the common mistakes that make tests hard to maintain.
What the AAA Pattern Is
Every test has exactly three sections:
Arrange: set up the system under test and its dependencies
Act: invoke the operation being tested
Assert: verify the outcomeThe pattern makes tests scannable. Any developer can read a test and immediately locate what is being tested (Act) and what is being verified (Assert).
Basic Example
[Fact]
public async Task Handle_valid_command_should_return_patient_id()
{
// Arrange
var patients = new FakePatientRepository();
var unitOfWork = new FakeUnitOfWork();
var handler = new CreatePatientCommandHandler(patients, unitOfWork);
var command = new CreatePatientCommand(
Name: "John Smith",
DateOfBirth: new DateOnly(1985, 3, 15),
MRN: "MRN-001");
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Value.Should().NotBe(Guid.Empty);
}Naming Conventions
Test names should read as a sentence describing the behavior:
// Pattern: {Method}_{Condition}_{ExpectedOutcome}
public async Task Handle_when_mrn_already_exists_should_return_failure()
public async Task Handle_with_valid_data_should_save_to_repository()
public async Task AddPrescription_to_inactive_patient_should_return_error()
public void Create_patient_with_future_dob_should_fail()
public void INRReading_above_4_should_require_urgent_review()
// Alternative: given-when-then
public async Task Given_duplicate_mrn_when_creating_patient_then_returns_conflict_error()Complete Handler Test Class
// tests/Application.UnitTests/Patients/AddPrescriptionCommandHandlerTests.cs
public class AddPrescriptionCommandHandlerTests
{
private readonly FakePatientRepository _patients;
private readonly FakeUnitOfWork _unitOfWork;
private readonly AddPrescriptionCommandHandler _sut;
// Arrange (shared): the handler under test
public AddPrescriptionCommandHandlerTests()
{
_patients = new FakePatientRepository();
_unitOfWork = new FakeUnitOfWork();
_sut = new AddPrescriptionCommandHandler(_patients, _unitOfWork);
}
[Fact]
public async Task Handle_valid_command_should_add_prescription_and_return_id()
{
// Arrange
var patient = Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001").Value;
_patients.Seed(patient);
var command = new AddPrescriptionCommand(
PatientId: patient.Id,
MedicationCode: "WARFARIN",
DosageAmount: 5m,
DosageUnit: "mg",
Frequency: "Once daily");
// Act
var result = await _sut.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(PrescriptionId.Empty);
_unitOfWork.SaveChangesCallCount.Should().Be(1);
}
[Fact]
public async Task Handle_patient_not_found_should_return_not_found_error()
{
// Arrange
var command = new AddPrescriptionCommand(
PatientId: PatientId.New(), // ID that does not exist in the fake repo
MedicationCode: "WARFARIN",
DosageAmount: 5m,
DosageUnit: "mg",
Frequency: "Once daily");
// Act
var result = await _sut.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(PatientErrors.NotFound);
_unitOfWork.SaveChangesCallCount.Should().Be(0);
}
[Fact]
public async Task Handle_inactive_patient_should_return_inactive_error()
{
// Arrange
var patient = Patient.Create("Jane Doe", new DateOnly(1990, 7, 22), "MRN-002").Value;
patient.Deactivate();
_patients.Seed(patient);
var command = new AddPrescriptionCommand(
patient.Id, "ASPIRIN", 100m, "mg", "Once daily");
// Act
var result = await _sut.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(PatientErrors.InactivePatient);
}
[Fact]
public async Task Handle_duplicate_active_prescription_should_return_error()
{
// Arrange
var patient = Patient.Create("Bob Jones", new DateOnly(1975, 11, 30), "MRN-003").Value;
var existingRx = BuildPrescription("WARFARIN");
patient.AddPrescription(existingRx);
_patients.Seed(patient);
var command = new AddPrescriptionCommand(
patient.Id, "WARFARIN", 5m, "mg", "Once daily"); // same drug
// Act
var result = await _sut.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(PatientErrors.DuplicateActivePrescription);
}
private static Prescription BuildPrescription(string code)
=> Prescription.Create(
MedicationCode.Create(code).Value,
Dosage.Create(5m, "mg").Value,
"Once daily").Value;
}Parameterized Tests With Theory
// tests/Application.UnitTests/Domain/DosageTests.cs
public class DosageTests
{
[Theory]
[InlineData(500, "mg")]
[InlineData(0.5, "mcg")]
[InlineData(10, "mL")]
[InlineData(100, "IU")]
[InlineData(2, "units")]
public void Create_with_valid_values_should_succeed(decimal amount, string unit)
{
// Arrange + Act
var result = Dosage.Create(amount, unit);
// Assert
result.IsSuccess.Should().BeTrue();
}
[Theory]
[InlineData(0, "mg", "AmountMustBePositive")]
[InlineData(-100, "mg", "AmountMustBePositive")]
[InlineData(100, "", "UnitRequired")]
[InlineData(100, "gallons", "InvalidUnit")]
public void Create_with_invalid_values_should_fail_with_expected_error(
decimal amount, string unit, string expectedErrorCode)
{
// Arrange + Act
var result = Dosage.Create(amount, unit);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Contain(expectedErrorCode);
}
}Avoid These Common Mistakes
// ✗ Multiple Acts in one test — which one failed?
[Fact]
public async Task Bad_test()
{
var r1 = await handler.Handle(command1, ct);
var r2 = await handler.Handle(command2, ct);
r1.IsSuccess.Should().BeTrue();
r2.IsFailure.Should().BeTrue();
}
// Fix: one test per behavior
// ✗ Assertions in Arrange — you're testing setup code, not the handler
[Fact]
public async Task Bad_arrangement()
{
var patient = Patient.Create(...).Value;
patient.Name.Should().Be("John"); // ← this is not an Act
// ...
}
// ✗ No clear separation — all three phases blended
[Fact]
public async Task Hard_to_read()
{
var result = await new CreatePatientCommandHandler(
new FakePatientRepository(),
new FakeUnitOfWork())
.Handle(new CreatePatientCommand("John", new DateOnly(1985,3,15), "MRN-001"), default);
Assert.True(result.IsSuccess);
}
// Fix: separate Arrange, Act, Assert with blank lines and commentsPRO TIP — Test Data Builders
When test setup requires many fields, use a builder pattern to avoid repetitive inline setup:
// tests/Builders/CreatePatientCommandBuilder.cs
public sealed class CreatePatientCommandBuilder
{
private string _name = "Default Patient";
private DateOnly _dob = new(1980, 1, 1);
private string _mrn = "MRN-DEFAULT";
public CreatePatientCommandBuilder WithName(string name) { _name = name; return this; }
public CreatePatientCommandBuilder WithMRN(string mrn) { _mrn = mrn; return this; }
public CreatePatientCommandBuilder WithDOB(DateOnly dob) { _dob = dob; return this; }
public CreatePatientCommand Build() =>
new CreatePatientCommand(_name, _dob, _mrn);
}
// In tests:
var command = new CreatePatientCommandBuilder()
.WithName("John Smith")
.WithMRN("MRN-999")
.Build();Key Takeaway
AAA is not just a naming convention — it is a test discipline that forces each test to verify exactly one behavior. When a test fails, you know which assertion failed and why. When you read a test, you know immediately what it is testing without reading the entire body. Good test names document the expected behavior of the system — they are the most up-to-date specification you have.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.