| name | write-tests |
| description | Writes xUnit tests for SBA use cases in the Fatturazione invoicing system. Use when creating tests for use cases, services, or validators. Follows the three-phase testing pattern (load failures, validation failures, execution success/failure) with NSubstitute mocking and FluentAssertions. Preserves the project's existing test conventions. |
| license | MIT |
| metadata | {"author":"Fabrizio Chignoli","version":"1.0.0","category":"testing","mcp-server":"none"} |
Skill: Write Tests for SBA Use Cases
You are writing xUnit tests for the Fatturazione invoicing system, specifically targeting SBA use cases that follow the three-phase pattern (Load, Validate, Execute).
Philosophy
Tests mirror the three phases of a use case. Each phase has distinct failure modes, and tests should cover all of them. A well-tested use case has:
- Load failure tests -- what happens when entities are not found?
- Validation failure tests -- what happens when business rules are violated?
- Execution success tests -- does the happy path produce correct results?
- Execution failure tests -- do edge cases in the execute phase work correctly?
Project Context
- Test framework: xUnit
- Assertions: FluentAssertions (
Should(), Be(), BeEmpty(), etc.)
- Mocking: NSubstitute (
Substitute.For<T>(), Returns(), Received())
- Test projects:
tests/Fatturazione.Domain.Tests/ -- domain services, validators, use cases
tests/Fatturazione.Api.Tests/ -- endpoint integration tests
- Naming: Test class =
{SubjectUnderTest}Tests.cs
- Pattern: Arrange-Act-Assert with
// Arrange, // Act, // Assert comments
- Conventions from existing code:
- Constructor sets up SUT and mocks (no
[SetUp] attribute)
- SUT stored as
_sut
- Mocks stored as
_{name}Mock or _{name}ServiceMock
#region blocks to group related tests
[Fact] for single-case tests, [Theory] with [InlineData] for parameterized
- XML doc comments on helper methods
- Italian error message assertions where applicable
Instructions
Step 1: Create the Test File
For a use case named IssueInvoice, create:
tests/Fatturazione.Domain.Tests/UseCases/IssueInvoiceTests.cs
Step 2: Set Up Test Class Structure
using Fatturazione.Domain.Exceptions;
using Fatturazione.Domain.Models;
using Fatturazione.Domain.Services;
using Fatturazione.Domain.UseCases;
using Fatturazione.Infrastructure.Repositories;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
namespace Fatturazione.Domain.Tests.UseCases;
public class IssueInvoiceTests
{
private readonly IInvoiceRepository _invoiceRepositoryMock;
private readonly IClientRepository _clientRepositoryMock;
private readonly IInvoiceNumberingService _numberingServiceMock;
private readonly IInvoiceCalculationService _calculationServiceMock;
private readonly ILogger<IssueInvoice> _loggerMock;
private readonly IssueInvoice _sut;
public IssueInvoiceTests()
{
_invoiceRepositoryMock = Substitute.For<IInvoiceRepository>();
_clientRepositoryMock = Substitute.For<IClientRepository>();
_numberingServiceMock = Substitute.For<IInvoiceNumberingService>();
_calculationServiceMock = Substitute.For<IInvoiceCalculationService>();
_loggerMock = Substitute.For<ILogger<IssueInvoice>>();
_sut = new IssueInvoice(
_invoiceRepositoryMock,
_clientRepositoryMock,
_numberingServiceMock,
_calculationServiceMock,
_loggerMock);
}
#region Helper Methods
private static Invoice CreateDraftInvoice(Guid? id = null, Guid? clientId = null)
{
var invoiceId = id ?? Guid.NewGuid();
var cId = clientId ?? Guid.NewGuid();
return new Invoice
{
Id = invoiceId,
InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(30),
ClientId = cId,
Status = InvoiceStatus.Draft,
Items = new List<InvoiceItem>
{
new InvoiceItem
{
Id = Guid.NewGuid(),
Description = "Consulenza",
Quantity = 1,
UnitPrice = 1000m,
IvaRate = IvaRate.Standard
}
}
};
}
private static Client CreateClient(Guid? id = null)
{
return new Client
{
Id = id ?? Guid.NewGuid(),
RagioneSociale = "Test SRL",
PartitaIva = "12345678903",
ClientType = ClientType.Company,
SubjectToRitenuta = false
};
}
private static IssueInvoiceRequest CreateRequest(Guid invoiceId)
{
return new IssueInvoiceRequest(invoiceId, ActorId: Guid.NewGuid());
}
#endregion
#region Load Failures
[Fact]
public async Task Execute_InvoiceNotFound_ThrowsNotFoundException()
{
var request = CreateRequest(Guid.NewGuid());
_invoiceRepositoryMock.GetByIdAsync(request.InvoiceId)
.Returns((Invoice?)null);
var act = () => _sut.Execute(request);
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*non trovato*");
}
[Fact]
public async Task Execute_ClientNotFound_ThrowsNotFoundException()
{
var invoice = CreateDraftInvoice();
var request = CreateRequest(invoice.Id);
_invoiceRepositoryMock.GetByIdAsync(invoice.Id).Returns(invoice);
_clientRepositoryMock.GetByIdAsync(invoice.ClientId).Returns((Client?)null);
var act = () => _sut.Execute(request);
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*Cliente*non trovato*");
}
#endregion
#region Validation Failures
[Fact]
public async Task Execute_InvoiceAlreadyIssued_ThrowsForbiddenOperationException()
{
var invoice = CreateDraftInvoice();
invoice.Status = InvoiceStatus.Issued;
var client = CreateClient(invoice.ClientId);
var request = CreateRequest(invoice.Id);
_invoiceRepositoryMock.GetByIdAsync(invoice.Id).Returns(invoice);
_clientRepositoryMock.GetByIdAsync(invoice.ClientId).Returns(client);
_invoiceRepositoryMock.GetLastInvoiceNumberAsync().Returns("2026/001");
var act = () => _sut.Execute(request);
await act.Should().ThrowAsync<ForbiddenOperationException>()
.WithMessage("*transizione*non consentita*");
}
[Theory]
[InlineData(InvoiceStatus.Sent)]
[InlineData(InvoiceStatus.Paid)]
[InlineData(InvoiceStatus.Cancelled)]
[InlineData(InvoiceStatus.Overdue)]
public async Task Execute_InvalidSourceStatus_ThrowsForbiddenOperationException(
InvoiceStatus invalidStatus)
{
var invoice = CreateDraftInvoice();
invoice.Status = invalidStatus;
var client = CreateClient(invoice.ClientId);
var request = CreateRequest(invoice.Id);
_invoiceRepositoryMock.GetByIdAsync(invoice.Id).Returns(invoice);
_clientRepositoryMock.GetByIdAsync(invoice.ClientId).Returns(client);
_invoiceRepositoryMock.GetLastInvoiceNumberAsync().Returns("2026/001");
var act = () => _sut.Execute(request);
await act.Should().ThrowAsync<ForbiddenOperationException>();
}
#endregion
#region Execution Success
[Fact]
public async Task Execute_ValidDraftInvoice_ReturnsIssuedInvoice()
{
var invoice = CreateDraftInvoice();
var client = CreateClient(invoice.ClientId);
var request = CreateRequest(invoice.Id);
_invoiceRepositoryMock.GetByIdAsync(invoice.Id).Returns(invoice);
_clientRepositoryMock.GetByIdAsync(invoice.ClientId).Returns(client);
_invoiceRepositoryMock.GetLastInvoiceNumberAsync().Returns("2026/001");
_numberingServiceMock.GenerateNextInvoiceNumber("2026/001").Returns("2026/002");
_invoiceRepositoryMock.UpdateAsync(Arg.Any<Invoice>())
.Returns(callInfo => callInfo.Arg<Invoice>());
var response = await _sut.Execute(request);
response.Invoice.Status.Should().Be(InvoiceStatus.Issued);
response.InvoiceNumber.Should().Be("2026/002");
}
[Fact]
public async Task Execute_ValidDraftInvoice_CalculatesTotalsBeforeIssuing()
{
var invoice = CreateDraftInvoice();
var client = CreateClient(invoice.ClientId);
var request = CreateRequest(invoice.Id);
_invoiceRepositoryMock.GetByIdAsync(invoice.Id).Returns(invoice);
_clientRepositoryMock.GetByIdAsync(invoice.ClientId).Returns(client);
_invoiceRepositoryMock.GetLastInvoiceNumberAsync().Returns((string?)null);
_numberingServiceMock.GenerateNextInvoiceNumber(null).Returns("2026/001");
_invoiceRepositoryMock.UpdateAsync(Arg.Any<Invoice>())
.Returns(callInfo => callInfo.Arg<Invoice>());
await _sut.Execute(request);
_calculationServiceMock.Received(1).CalculateInvoiceTotals(
Arg.Is<Invoice>(i => i.Id == invoice.Id));
}
[Fact]
public async Task Execute_ValidDraftInvoice_PersistsUpdatedInvoice()
{
var invoice = CreateDraftInvoice();
var client = CreateClient(invoice.ClientId);
var request = CreateRequest(invoice.Id);
_invoiceRepositoryMock.GetByIdAsync(invoice.Id).Returns(invoice);
_clientRepositoryMock.GetByIdAsync(invoice.ClientId).Returns(client);
_invoiceRepositoryMock.GetLastInvoiceNumberAsync().Returns((string?)null);
_numberingServiceMock.GenerateNextInvoiceNumber(null).Returns("2026/001");
_invoiceRepositoryMock.UpdateAsync(Arg.Any<Invoice>())
.Returns(callInfo => callInfo.Arg<Invoice>());
await _sut.Execute(request);
await _invoiceRepositoryMock.Received(1).UpdateAsync(
Arg.Is<Invoice>(i =>
i.Status == InvoiceStatus.Issued &&
i.InvoiceNumber == "2026/001"));
}
[Fact]
public async Task Execute_FirstInvoice_AssignsFirstNumber()
{
var invoice = CreateDraftInvoice();
var client = CreateClient(invoice.ClientId);
var request = CreateRequest(invoice.Id);
_invoiceRepositoryMock.GetByIdAsync(invoice.Id).Returns(invoice);
_clientRepositoryMock.GetByIdAsync(invoice.ClientId).Returns(client);
_invoiceRepositoryMock.GetLastInvoiceNumberAsync().Returns((string?)null);
_numberingServiceMock.GenerateNextInvoiceNumber(null).Returns("2026/001");
_invoiceRepositoryMock.UpdateAsync(Arg.Any<Invoice>())
.Returns(callInfo => callInfo.Arg<Invoice>());
var response = await _sut.Execute(request);
response.InvoiceNumber.Should().Be("2026/001");
}
#endregion
#region Execution Edge Cases
[Fact]
public async Task Execute_ValidInvoice_SetsClientOnInvoiceBeforeCalculation()
{
var invoice = CreateDraftInvoice();
var client = CreateClient(invoice.ClientId);
var request = CreateRequest(invoice.Id);
_invoiceRepositoryMock.GetByIdAsync(invoice.Id).Returns(invoice);
_clientRepositoryMock.GetByIdAsync(invoice.ClientId).Returns(client);
_invoiceRepositoryMock.GetLastInvoiceNumberAsync().Returns("2026/005");
_numberingServiceMock.GenerateNextInvoiceNumber("2026/005").Returns("2026/006");
_invoiceRepositoryMock.UpdateAsync(Arg.Any<Invoice>())
.Returns(callInfo => callInfo.Arg<Invoice>());
await _sut.Execute(request);
_calculationServiceMock.Received(1).CalculateInvoiceTotals(
Arg.Is<Invoice>(i => i.Client == client));
}
#endregion
#region Interaction Verification
[Fact]
public async Task Execute_InvoiceNotFound_DoesNotCallRepository()
{
var request = CreateRequest(Guid.NewGuid());
_invoiceRepositoryMock.GetByIdAsync(request.InvoiceId).Returns((Invoice?)null);
var act = () => _sut.Execute(request);
await act.Should().ThrowAsync<NotFoundException>();
await _invoiceRepositoryMock.DidNotReceive().UpdateAsync(Arg.Any<Invoice>());
_calculationServiceMock.DidNotReceive().CalculateInvoiceTotals(Arg.Any<Invoice>());
}
[Fact]
public async Task Execute_ValidationFails_DoesNotPersist()
{
var invoice = CreateDraftInvoice();
invoice.Status = InvoiceStatus.Paid;
var client = CreateClient(invoice.ClientId);
var request = CreateRequest(invoice.Id);
_invoiceRepositoryMock.GetByIdAsync(invoice.Id).Returns(invoice);
_clientRepositoryMock.GetByIdAsync(invoice.ClientId).Returns(client);
_invoiceRepositoryMock.GetLastInvoiceNumberAsync().Returns("2026/001");
var act = () => _sut.Execute(request);
await act.Should().ThrowAsync<ForbiddenOperationException>();
await _invoiceRepositoryMock.DidNotReceive().UpdateAsync(Arg.Any<Invoice>());
}
#endregion
}
Test Structure Rules
File Organization
tests/Fatturazione.Domain.Tests/
UseCases/
IssueInvoiceTests.cs
CreateCreditNoteTests.cs
TransitionInvoiceTests.cs
Services/
InvoiceCalculationServiceTests.cs (existing)
CreditNoteServiceTests.cs (existing)
Validators/
InvoiceValidatorTests.cs (existing)
Naming Conventions
| Element | Convention | Example |
|---|
| Test class | {SubjectUnderTest}Tests | IssueInvoiceTests |
| Test method | {Method}_{Scenario}_{ExpectedResult} | Execute_InvoiceNotFound_ThrowsNotFoundException |
| Mock fields | _{name}Mock or _{name}ServiceMock | _invoiceRepositoryMock |
| SUT field | _sut | private readonly IssueInvoice _sut; |
| Helper methods | Descriptive static methods | CreateDraftInvoice() |
Region Structure
Organize tests into #region blocks matching the three phases:
#region Helper Methods
#endregion
#region Load Failures
#endregion
#region Validation Failures
#endregion
#region Execution Success
#endregion
#region Execution Edge Cases
#endregion
#region Interaction Verification
#endregion
What to Test for Each Phase
Phase 1 (Load) Tests
- Entity not found ->
ThrowAsync<NotFoundException>
- Each required entity gets its own not-found test
- Verify that downstream phases are NOT executed when load fails
[Fact]
public async Task Execute_InvoiceNotFound_ThrowsNotFoundException()
{
_invoiceRepositoryMock.GetByIdAsync(Arg.Any<Guid>()).Returns((Invoice?)null);
var act = () => _sut.Execute(request);
await act.Should().ThrowAsync<NotFoundException>();
await _invoiceRepositoryMock.DidNotReceive().UpdateAsync(Arg.Any<Invoice>());
}
Phase 2 (Validate) Tests
- Invalid state transition ->
ThrowAsync<ForbiddenOperationException>
- Invalid input data ->
ThrowAsync<InvalidInputException>
- Use
[Theory] with [InlineData] for multiple invalid states
- Verify that persistence is NOT called when validation fails
[Theory]
[InlineData(InvoiceStatus.Sent)]
[InlineData(InvoiceStatus.Paid)]
[InlineData(InvoiceStatus.Cancelled)]
public async Task Execute_InvalidStatus_ThrowsForbiddenOperationException(
InvoiceStatus status)
{
invoice.Status = status;
var act = () => _sut.Execute(request);
await act.Should().ThrowAsync<ForbiddenOperationException>();
await _repositoryMock.DidNotReceive().UpdateAsync(Arg.Any<Invoice>());
}
Phase 3 (Execute) Tests
- Happy path returns correct response
- Calculation services are called with correct arguments
- Persistence receives the correctly mutated entity
- Side effects (if any) are triggered
- Edge cases: first invoice, null previous number, etc.
[Fact]
public async Task Execute_ValidInvoice_PersistsWithCorrectStatus()
{
var response = await _sut.Execute(request);
response.Invoice.Status.Should().Be(InvoiceStatus.Issued);
await _repositoryMock.Received(1).UpdateAsync(
Arg.Is<Invoice>(i => i.Status == InvoiceStatus.Issued));
}
Mock Setup Patterns
Repository Mocks
_invoiceRepositoryMock.GetByIdAsync(invoiceId).Returns(invoice);
_invoiceRepositoryMock.GetByIdAsync(Arg.Any<Guid>()).Returns((Invoice?)null);
_invoiceRepositoryMock.UpdateAsync(Arg.Any<Invoice>())
.Returns(callInfo => callInfo.Arg<Invoice>());
await _invoiceRepositoryMock.Received(1).UpdateAsync(
Arg.Is<Invoice>(i => i.Status == InvoiceStatus.Issued));
await _invoiceRepositoryMock.DidNotReceive().UpdateAsync(Arg.Any<Invoice>());
Service Mocks
_calculationServiceMock.Received(1).CalculateInvoiceTotals(
Arg.Is<Invoice>(i => i.Client != null));
_numberingServiceMock.GenerateNextInvoiceNumber("2026/001").Returns("2026/002");
_ritenutaServiceMock.AppliesRitenuta(client).Returns(true);
_ritenutaServiceMock.CalculateRitenuta(1000m, client).Returns(200m);
Logger Mock
_loggerMock = Substitute.For<ILogger<IssueInvoice>>();
Running Tests
dotnet test
dotnet test --filter "FullyQualifiedName~UseCases"
dotnet test --filter "FullyQualifiedName~IssueInvoiceTests"
dotnet test --verbosity normal
Checklist