#dev #dotnet #testing

From Chaos to Confidence: A Practical Guide to Unit Testing in Clean Architecture

From Chaos to Confidence: A Practical Guide to Unit Testing in Clean Architecture
Photo by National Cancer Institute / Unsplash

The Problem: A Growing Codebase, Growing Concerns

Let's start by asking ourselves why do we need unit tests?

  • Catch Bugs Early – Identify issues in individual components before integration.
  • Improve Code Quality – Enforce better coding practices and maintainability as writing code for testing ensures adhering to the coding architecture and when a function is testable it's usually readable and less-prone to errors.
  • Facilitate Refactoring – Allow us to refactor with confidence.
  • Enhance Development Speed – You might say "How is that! I am spending more time writing the code itself then the tests!" and that's true when looking at one simple task, however, when you're building a big project, through multiple iterations, it definitely reduces your testing time overhead and it makes later additions easier.
  • Support Continuous Integration – Ensure code stability in automated pipelines, as unit tests are very very fast to run, so it is a convenient step to add to your pipeline without worrying about performance and ensuring your code maintains its functionality with every release.

Now that we've talked about the importance of unit tests, it looks very appealing but there are some downfalls we wanted to avoid and some specific goals we wanted to achieve within our application.

Goals:

  • Easy to maintain and grow - We wanted them to be easy to edit or require no edits when there is a code change. We were very cautious approaching this project; unit tests have a bad reputation regarding their maintainability. When developers only think about code coverage and a specific percentage to adhere to, it ruins the whole point.
  • Valuable tests only - what do we mean by valuable tests? A valuable test is a test that prevents regression, ensures business logic is achieved and is not trivial.

The Journey: Finding Our Testing Sweet Spot :

Understanding Our Ecosystem

Before diving into our approach, it’s important to understand the structure of our application. We use .NET (C#) with EF Core as our ORM, and our architecture follows a clean separation of concerns:

  • Controllers – Responsible for routing only, containing no business logic.
  • Services – Contain all business logic and act as the core of the application.
  • External Services - Handle interactions with any external service, like third party integrations, cloud solutions, data sources, etc.
  • Repositories – Handle all interactions with the database.
  • Dependency Inversion – Ensures loose coupling between these components.

Given this structure, the natural question was: Where should we focus our unit tests? And what is our sweet spot?

Choosing what to test?

Since business logic resides in the service layer, it became clear that our primary focus should be testing services. Testing repositories would mean interacting with the database, which is better suited for integration tests rather than unit tests. Controllers, on the other hand, only handle routing, making them less critical for unit testing. Thus, our solution was straightforward: unit test the service layer in isolation while mocking dependencies like repositories and external services. This allows us to:

  • Validate business rules without relying on actual database calls.
  • We achieve better maintainability since services encapsulate the application's domain logic.
  • Keep tests fast and deterministic.
  • Prevent unnecessary breakage when implementation details change as we reduce test coupling to implementation details, ensuring tests are resilient to changes in other layers... - YES, such dreams can come true!

Best Practices And Guidelines for Test Creation:

  1. Identify Core Logic: Focus on methods with significant business logic like calculations, validations, and core operations.
  2. Ensure Single Responsibility: Each test should validate one behavior.
  3. Keep Tests Independent: Ensure tests do not depend on each other or shared state.
  4. Mock External Dependencies: Use mocks for database access and external dependencies. Keep them light and specific to the test.
  5. Validate Outcomes: Focus assertions on the results rather than internal implementation details.
  6. Fail Fast: Write tests that fail clearly and give enough information to debug quickly.
  7. Define Test Scenarios: Identify edge cases and common use cases for each method. Ensure all critical logic paths are tested.
  8. Refactor for Testability: If needed, refactor code to simplify dependencies and improve testability.
  9. Use Descriptive Names: Use clear and meaningful test names that describe the scenario and the expected outcome.

Adhering to these standards ensures robust, maintainable, and effective unit tests.

The Solution: A Practical Testing Approach

Technical Components Overview

  • Testing Framework: XUnit Docs
  • Mocking Framework: FakeItEasy
  • Assertion Library: FluentAssertions

A problem we faced when deciding how the new testing project would access the service, since it is by design, not accessible. We came across a couple of solutions but the easiest, one-time, and neat solution was to expose the internals of the project to be tested to be seen by the testing project. Here is how you can define it.

The Apps.Tests project has access to internal members of Apps.Services by using the InternalsVisibleTo attribute in the Apps.Services.csproj file:

<ItemGroup>
    <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
        <_Parameter1>Apps.Tests</_Parameter1>
    </AssemblyAttribute>
</ItemGroup>

Writing Tests

Let's examine a real-world example of testing a document upload service to demonstrate our testing principles in action.

Service Under Test (SUT)

We'll look at a document upload service that handles:

  • Document uploading and replacement
  • Metadata management
  • Integration with external storage
  • Document attribute management

This is an excellent example as it contains:

  • Core business logic (document naming rules, attribute management)
  • External dependencies (storage service, repository)
  • Complex validation rules
  • Multiple paths of execution
public async Task UploadDocument(DocumentMetaData docData, bool replace, FileData file)
{
    var transactionTime = DateTime.UtcNow;
    var userId = _ctx.CurrentUserId();

    await ValidateDocumentsAttributes(docData.AttributesIds);
    await ValidateDocumentType(docData.DocTypeId);
    ValidateAssetType(docData.AssetType);
    AssertFileExtensionIsCorrect(docData.Extension);

    ConcatenateExtensionToDocName(docData.Extension, docData);

    if (replace)
    {
        return await ReplaceDocumentAndUpdateMetaData(docMetaData, file, userId, transactionTime);
    }

    List<Document> docWithName = await _repository.GetDocumentByName(docMetaData.DocName);

    AssertDocumentIsNotDuplicatedForAsset(docMetaData, docWithName);

    docMetaData.DocumentEntities.Add(new DocumentAssociation
    {
        AssetId = docMetaData.AssetId,
        AssetType = docMetaData.AssetType
    });

    return await CreateDocumentAndUploadToCloud(docMetaData, file, userId, transactionTime);
}

private async Task<int> ReplaceDocumentAndUpdateMetaData(DocumentMetaData docMetaData, FileData file, int userId, DateTime modifiedAt)
{
    var dbDocument = await _repository.GetDocumentByNameAndAssetAssociation(docMetaData.DocName, docMetaData.AssetType, docMetaData.AssetId);

    Assert(dbDocument != null, "documentNotFound");

    await _documentStorageExternalService.UploadDocument(dbDocument.Name, dbDocument.NameInStorage, file, ExternalStorageContext.ApplicationDocuments);

    using(var transaction = _dbContext.Database.BeginTransaction())
    { 
        await UpdateDocumentAttributes(dbDocument, docMetaData.AttributesIds, userId, modifiedAt);
        await ReplaceDocumentMetaData(file, dbDocument, docMetaData, userId, modifiedAt);
        transaction.Commit();
    }

    return dbDocument.Id;
}

private async Task<int> CreateDocumentAndUploadToCloud(DocumentMetaData docMetaData, FileData file, string extension, int userId, DateTime createdAt)
{
    string documentNameInStorage = GenerateDocumentName();
    var absoluteUriInStorage = await _documentStorageExternalService.UploadDocument(docMetaData.DocName, documentNameInStorage, file, ExternalStorageContext.ApplicationDocuments);

    return await _repository.InsertDocument(file, docMetaData, documentNameInStorage, absoluteUriInStorage, extension, userId, createdAt);
}

Setting Up the Test Class

public class DocumentsServiceTests
{
    private readonly IDocumentsRepository _repository;
    private readonly IContext _ctx;
    private readonly DBContext _dbContext;
    private readonly IDocumentStorageExternalService _documentStorageExternalService;

    private readonly IDocumentsService _service;

    public DocumentsServiceTests()
    {
        _repository = A.Fake<IDocumentsRepository>();
        _documentStorageExternalService = A.Fake<IDocumentStorageExternalService>();
        _ctx = A.Fake<IContext>();
        _dbContext = A.Fake<DBContext>();

        _service = new DocumentsService(_repository, _ctx, _dbContext, _documentStorageExternalService);
    }
}
Set up the constructor, use fakes for injected dependencies, initiate the Service under test with the mocked dependencies.
  A.CallTo(() => _repository.UpdateDocumentMetaData(A<Document>._)).Returns(1);

  A.CallTo(() => _repository.GetAttributesByIds(A<List<int>>._));
  A.CallTo(() => _repository.GetDocumentType(A<int>._));

  A.CallTo(() => _documentStorageExternalService.UploadDocument(A<string>._, A<string>._, A<FileData>._)).Returns("absoluteUriInCloud");
  
  A.CallTo(() => _repository.GetDocumentByNameAndAssetAssociation(A<string>._, A<string>._, A<int>._)).Returns(new Document { Id = 1, Name = "DocName.docx" });
Mocking Dependencies (This is a sample of mocked external calls.)
public static IEnumerable<object[]> UploadDocumentSuccess_Data =>
    new List<object[]>
    {
         new object[]
         {
             new DocumentMetaData {
	             Name = "DocumentName",
	             AttributesId = new List<int> { 1, 2, 3 },
	             Extension = "xlsx",
	             DocTypeId = 1,
	             DocType = AssetsWithDocuments.Deal
             },
             new List<DocAttribute> {
                 new DocAttribute { Id = 1, Name = "atr1" },
                 new DocAttribute { Id = 2, Name = "atr2" },
                 new DocAttribute { Id = 3, Name = "atr3" }
             },
         }
    };
Testing Data
result.Should().NotBeNull();
result.ValidRecords.Should().HaveCount(1);
Using FluentAssertions for better readability and expressive tests

Example Tests

Here's how we test different aspects of the document upload functionality:

1. Validation Rules Testing

[Fact]
public async Task UploadDocumentReplaceCannotChangeNameError()
{
    // Arrange
    A.CallTo(() => _repository.UpdateDocumentMetaData(A<Document>._)).Returns(1);
    A.CallTo(() => _repository.GetAttributesByIds(A<List<int>>._));
    A.CallTo(() => _repository.GetDocumentType(A<int>._));
    A.CallTo(() => _documentStorageExternalService.UploadDocument(A<string>._, A<string>._, A<FileData>._))
        .Returns("absoluteUriInCloud");
        
    A.CallTo(() => _repository.GetDocumentByNameAndAssetAssociation(A<string>._, A<string>._, A<int>._))
        .Returns(new Document { Id = 1, Name = "DocName.docx" });

    // Act & Assert
   await _service.Invoking(s => s.UploadDocument(
       new DocumentMetaData {
           "CHANGEDdocName",
           1,
           A.Dummy<List<int>>(),
           "docx",
           true,
           1,
           AssetsWithDocuments.Deal
       },
       A.Dummy<List<DocumentAssociation>>(),
       A.Dummy<FileData>()
       ))
	.Should().ThrowAsync<CustomException>()
    .Where(e => e.ErrorKey == ErrorKey.DocumentNameChangeException);
}

This test demonstrates several of our best practices:

  • Single Responsibility: Tests one specific business rule (document name cannot change during replacement)
  • Clear Arrangement: Dependencies are mocked with specific returns
  • Explicit Assertion: Tests for the exact error message
  • Focused Scope: Only mocks what's necessary for this specific test

Also, note the naming convention MethodName_StateUnderTest_ExpectedBehavior

2. Complex Operation Testing

[Fact]
public async Task UploadDocument_Replace_UpdateIsCalledSuccess()
{
    // Arrange
    A.CallTo(() => _repository.UpdateDocumentMetaData(A<Document>._)).Returns(1);
    A.CallTo(() => _repository.GetAttributesByIds(A<List<int>>._))
        .Returns(new List<DocAttribute> {
            new DocAttribute { Id = 1, Name = "atr1" },
            new DocAttribute { Id = 2, Name = "atr2" },
            new DocAttribute { Id = 3, Name = "atr3" }
        });
    A.CallTo(() => _repository.GetDocumentType(A<int>._));
    A.CallTo(() => _documentStorageExternalService.UploadDocument(A<string>._, A<string>._, A<FileData>._))
        .Returns("absoluteUriInCloud");
        
    A.CallTo(() => _repository.GetDocumentByNameAndAssetAssociation(A<string>._, A<string>._, A<int>._))
        .Returns(new Document
        {
            Id = 1,
            Name = "DocName.docx",
            DocumentAttributes = new List<DocumentAttribute> {
                new DocumentAttribute { AttributeId = 1 },
                new DocumentAttribute { AttributeId = 2 },
                new DocumentAttribute { AttributeId = 5 }
            }
        });

    // Act
    var result = await _service.UploadDocument(
        new DocumentMetaData {
            Name = DocName",
            1,
            A.Dummy<List<int>>(),
            "docx",
            Replace = true,
            1,
            AssetsWithDocuments.Deal
        }
        ,
        A.Dummy<List<DocumentAssociation>>(),
        new FileData { FileName = "", FileContent = new byte[] { 20 } }
    );

    // Assert
    A.CallTo(() => _repository.UpdateDocumentMetaData(A<Document>._))
        .MustHaveHappenedOnceExactly();
}

This test showcases:

  • Complex State Setup: Multiple dependencies configured with specific data
  • Behavior Verification: Ensures the update method is called exactly once

3. External Integration Testing

[Fact]
public async Task UploadDocument_CallsAzureBlobStorageUpload()
{
    // Arrange
    A.CallTo(() => _repository.InsertDocument(
        A<FileData>._,
        A<DocumentMetaData>._,
        A<string>._,
        A<string>._,
        A<string>._,
        1,
        DateTime.UtcNow
    )).Returns(1);
    
    A.CallTo(() => _repository.GetAttributesByIds(A<List<int>>._));
    A.CallTo(() => _repository.GetDocumentType(A<int>._));
    
    A.CallTo(() => _repository.GetDocumentByName(A<string>._))
        .Returns(new List<Document> { new Document { } });
        
    A.CallTo(() => _documentStorageExternalService.UploadDocument(
        A<string>._,
        A<string>._,
        A<FileData>._
    )).Returns("documentUrl");

    // Act
    var result = await _service.UploadDocument(
        "docname",
        1,
        A.Dummy<List<int>>(),
        "docx",
        false,
        1,
        AssetsWithDocuments.Deal,
        A.Dummy<List<DocumentAssociation>>(),
        A.Dummy<FileData>()
    );

    // Assert
    A.CallTo(() => _documentStorageExternalService.UploadDocument(
        A<string>._,
        A<string>._,
        A<FileData>._
    )).MustHaveHappenedOnceExactly();
}

This test illustrates:

  • External Service Integration: Verifies interaction with blob storage
  • Minimal Mocking: Only mocks what's necessary for the test
  • Clear Intent: Test name clearly indicates what's being verified

Key Takeaways from These Examples

  1. Focus on Business Rules: Each test validates a specific business requirement or behavior
  2. Clear Arrangement: Dependencies are explicitly mocked with relevant data
  3. Precise Assertions: Tests verify exact behaviors or outcomes
  4. Independent Tests: Each test stands alone and doesn't depend on others
  5. Maintainable Structure: Tests are organized by functionality and follow a consistent pattern
  6. Descriptive naming: to make test intentions clear:

Running Tests

Locally

Use the Test Explorer in Visual Studio to run and debug tests.

In the Pipeline

  • Tests run automatically in the build pipeline.
  • Results are visible in the build summary under the Tests section.
Test Run result on Azure DevOps Build Pipeline

Key Takeaways: What We Learned

  1. Focus on What Matters: Not all code needs the same level of testing. Focus on your business logic.
  2. Test Behavior, Not Implementation: Our tests validate what the code should do, not how it does it.
  3. Keep Tests Simple: Each test should tell a clear story about a specific requirement.
  4. Mock Wisely: Mock external dependencies, but keep the mocks simple and focused.

Remember, the goal isn't 100% code coverage - it's having confidence that your critical business logic works correctly.