Writing code without unit tests can be risky because you might not realize issues until it’s too late. Unit testing helps to ensure that your code actually does what it’s supposed to do. And it also provides reassurance that you won’t break existing functionality when adding new features or fixing something in the future.

What Is Unit Testing?

Unit testing is all about testing small, isolated chunks (units) of code to make sure they work correctly. Instead of waiting until the whole application is built and then doing manual testing and trying to fix bugs, unit tests let you catch these issues early. In C# there are several frameworks, such as NUnit , xUnit , or MSTest that are used to write and run these tests.

Tests, when done correctly, generally help with a couple of things:

  • Find Bugs Early: Fixing issues sooner rather than later saves time (and headaches).

  • Make Your Code More Maintainable: Well-tested code is easier to update and improve.

  • Write Better Code: Writing testable code forces you to structure it well. It steers development towards loose coupling and single responsibility.

  • Acts as Documentation: Your tests describe what your code is supposed to do. If written well, they can act as use-case documentation.

What Is Test-Driven Development (TDD)?

TDD is a mindset shift where you write your tests before you write the actual implementation. It follows a simple cycle:

  1. Write a test that fails (because the functionality doesn’t exist yet)
  2. Write just enough code to make the test pass
  3. Refactor and clean up the code while keeping the test green
  4. Go back to step 1.

Write a test that fails (because the functionality doesn’t exist yet)

Write just enough code to make the test pass

Refactor and clean up the code while keeping the test green

Next Functionality

TDD can help with some, if not all, of the following:

  • Guarantees better test coverage.

  • Helps to write only the code is actually needed.

  • Saves time when debugging later.

  • Makes the code modular and easier to change.

  • Every test case serves as a description of a use case, making tests a form of living documentation for your code. This is significant addition to technical documentation, because it is remain up to date, as it is directly dependent on the code itself.

  • Encourages better code structure by promoting loose coupling, dependency injection, and composition-based design patterns.

What is Mocking?

Unit testing has one architectural problem that needs to be solved, and that is when it comes to the testing of external dependency. It needs to somehow isolate these dependencies and be able to test only the unit for which test is written. To be able to do this, we need to preconfigure these external dependencies to behave in a way that we expect, so we can rely on them returning always the same result when given the same input. With this, we are ensuring that our unit test is testing one thing only, without having to worry about dependencies.

There are couple of mocking frameworks that allow us to preconfigure external dependencies behavior. In this example, we will use Moq as it is a widely used one.

Step-by-Step Guide: Feature Definition and Architecture Draft

Now, let’s start with the real use case. Let’s say that we need to implement IAM (Identity and access management) module for the ASP.NET Core Web API. For the sake of simplicity and to keep the article short enough, we will focus only on a login feature. The login feature means that a user will be able to log in using his username and password and, if everything is correct, the API will return auth and refresh token. These tokens will be used later for accessing other endpoints that need authorized user and refreshing the auth token respectively.

From an architectural perspective, let’s say that we will have a simple data flow from the Controller down to the Service and finally to Database.

Client

      

AuthenticationController

      

AuthenticationService

   

UserDbContext

      

JWT + Refresh Token

Step 1: Setting Up NUnit in Your Project

  • For separation of concerns, we can create two projects inside the solution, one that contains the logic we want to test, and another one for tests (NUnit Test Project).

  • Install NUnit and NUnit3TestAdapter via NuGet if needed.

  • Add a reference to the project containing the code you want to test.

  • To start, the folder/file structure could look like the one below:

OptimalCoder.BlueprintOptimalCoder.Blueprint.APIControllersAuthenticationController.csOptimalCoder.Blueprint.DBContextUserDbContext.csEntitiesRole.csUser.csOptimalCoder.Blueprint.IAMAuthenticationAuthenticationService.csOptimalCoder.Blueprint.SharedConfigAppSettings.csJwt.csOptimalCoder.Blueprint.TestsUnitTestsIAMAuthenticationTests.cs
"Jwt": {
    "Key": "",
    "Issuer": "optimalcoder.net",
    "Audiences": [
        "https://localhost:44322/"
    ],
    "TokenValidityInMinutes": 1,
    "RefreshTokenValidityInDays": 7
}

Step 2: Define the Detailed Requirements for the Feature

Let’s use TDD to build the AuthenticationService class with Login method. First, create a test class AuthenticationTests.cs. Tests should describe the requirements for our Login feature:

  • When the email is not confirmed, it needs to throw an UnauthorizedException with message “EMAIL_NOT_CONFIRMED”
  • When the password does not match, it needs to throw an UnauthorizedException with message “INVALID_CREDENTIALS”
  • When both are valid, it needs to return auth and refresh token and store their hashes in the DB

Step 3: Write Your Test Based on Requirement

Lets take the first requirement and create a test that describes it, keeping in mind the overall architecture we agreed upon:

 [Test]
 public async Task Login_WhenEmailNotConfirmed_ThrowEx()
 {
    var user = new User()
    {
        Id = 1,
        EmailConfirmed = false,
        Locked = false,
        RefreshTokenHash = "RefreshTokenHash",
        PasswordHash = "PasswordHash",
        UserName = "UserName",
        RefreshTokenExpiryTime = DateTime.UtcNow.AddMinutes(1),
        Roles = new List<Role>() 
        { 
            new Role()
            {
                Id = 1,
                Name= "User"
            } 
        }
    };

    var userContextMock = new Mock<UserDbContext>();
    userContextMock.SetupGet(x => x.User).ReturnsDbSet([ user ]);

    var appSettings = Options.Create(new AppSettings()
        {
            Jwt = new Jwt()
            {
                Audiences = ["audience"],
                Issuer = "issuer",
                Key = "my-super-secret-key-with-more-than-16-characters",
                RefreshTokenValidityInDays = 10,
                TokenValidityInMinutes = 2
            }
        });

    var authService = new AuthenticationService(userContextMock.Object, _appSettings);

     var ex = Assert.ThrowsAsync<UnauthorizedException>(async () 
            => await authService.Login(new UserLoginModel()
     {
         UserName = "UserName",
         Password = "password"
     }))!;

     Assert.AreEqual("EMAIL_NOT_CONFIRMED", ex.Code);
 }

We needed to isolate UserDbContext from the Login unit by mocking it. Another dependency is JWT Configuration injected from appsettings.json.

Step 4: Write Your Code to Support the Test

 public interface IAuthenticationService
 {
     Task<TokenResponse> Login(UserLoginModel user);
 }

 public class AuthenticationService : IAuthenticationService
 {
     private readonly UserDbContext _userDbContext;
     private readonly Jwt _jwtConfig;

     public AuthenticationService(UserDbContext userDbContext, 
            IOptions<AppSettings> appSettings)
     {
         _userDbContext = userDbContext;
         _jwtConfig = appSettings.Value.Jwt;
     }

     public async Task<TokenResponse> Login(UserLoginModel model)
     {
        var user = await _userDbContext.User.Include(u => u.Roles)
                .FirstOrDefaultAsync(x => x.UserName == model.UserName);
         
        if (!user.EmailConfirmed)
        {
            throw new UnauthorizedException("EMAIL_NOT_CONFIRMED", 
                        "Email not confirmed.");
        }

         return null;
     }
}

Step 5: Refactoring if Needed

We see that our test method is too broad because it has to deal with different things related to the initialization of UserDbContext and AppSettings. Let’s move them outside:

[TestFixture]
public class AuthenticationTests
{
    private AuthenticationService _authService;
    private User _user;
    private Mock<UserDbContext> _userContextMock;

    private readonly IOptions<AppSettings> _appSettings = Options
            .Create(new AppSettings()
    {
        Jwt = new Jwt()
        {
            Audiences = ["audience"],
            Issuer = "issuer",
            Key = "my-super-secret-key-with-more-than-16-characters",
            RefreshTokenValidityInDays = 10,
            TokenValidityInMinutes = 2
        }
    });

    [SetUp]
    public void Init()
    {
        _user = new()
        {
            Id = 1,
            EmailConfirmed = true,
            Locked = false,
            RefreshTokenHash = "RefreshTokenHash",
            PasswordHash = "PasswordHash",
            UserName = "UserName",
            RefreshTokenExpiryTime = DateTime.UtcNow.AddMinutes(1),
            Roles = new List<Role>() { new Role()
        {
            Id = 1,
            Name= "User"
        } }
        };
        _userContextMock = new Mock<UserDbContext>();
        _userContextMock.SetupGet(x => x.User).ReturnsDbSet([ _user ]);

        _authService = new AuthenticationService(_userContextMock.Object, 
                _appSettings);
    }

    [Test]
    public async Task Login_WhenEmailNotConfirmed_ThrowEx()
    {
        _user.EmailConfirmed = false;

        var ex = Assert.ThrowsAsync<UnauthorizedException>(async () 
                    => await _authService.Login(new UserLoginModel()
        {
            UserName = "UserName",
            Password = "password"
        }))!;

        Assert.AreEqual("EMAIL_NOT_CONFIRMED", ex.Code);
    }

Step 6: Additional Tests and Logic Implementation

Now, we understand the main loop when applied to a real example. Following the steps from the beginning of the article, we should end up with something like this:

[Test]
public async Task Login_WhenDifferentPasswordHash_ThrowEx()
{
    _passwordServiceMock.Setup(x => x.Hash(_user, "password"))
                    .Returns("wrong password hash");

    var ex = Assert.ThrowsAsync<UnauthorizedException>(async () 
                => await _authService.Login(new UserLoginModel()
    {
        UserName = _user.UserName,
        Password = "password"
    }))!;
    Assert.AreEqual("INVALID_CREDENTIALS", ex.Code);
}

[Test]
public async Task Login_WhenUSernamePasswordOk_ReturnTokens()
{
    _passwordServiceMock.Setup(x => x.Verify(_user, "password", "PasswordHash"))
                    .Returns(true);

    var tokens = await _authService.Login(new UserLoginModel()
    {
        UserName = _user.UserName,
        Password = "password"
    });

    Assert.IsNotNull(tokens);
    Assert.IsNotEmpty(tokens.AuthToken);
    Assert.IsNotEmpty(tokens.RefreshToken);
}
 public class AuthenticationService : IAuthenticationService
 {
     private readonly UserDbContext _userDbContext;
     private readonly IPasswordService _passwordService;
     private readonly Jwt _jwtConfig;

     public AuthenticationService(UserDbContext userDbContext, 
            IPasswordService passwordService,  IOptions<AppSettings> appSettings)
     {
         _userDbContext = userDbContext;
         _passwordService = passwordService;
         _jwtConfig = appSettings.Value.Jwt;
     }


     public async Task<TokenResponse> Login(UserLoginModel model)
     {
         var user = await _userDbContext.User.Include(u => u.Roles)
                            .FirstOrDefaultAsync(x => x.UserName == model.UserName);
         
         if (user == null)
         {
             throw new UnauthorizedException("INVALID_CREDENTIALS", 
                        "Invalid username or password.");
         }

         if (!user.EmailConfirmed)
         {
             throw new UnauthorizedException("EMAIL_NOT_CONFIRMED", 
                        "Email not confirmed.");
         }

         var passwordValid = _passwordService.Verify(user, model.Password, 
                    user.PasswordHash);

         if (!passwordValid)
         {
             throw new UnauthorizedException("INVALID_CREDENTIALS", 
                            "Invalid username or password.");
         }

         var authToken = GenerateAuthToken(user);
         var refreshToken = GenerateRefreshToken();

         user.RefreshTokenHash = HashRefreshToken(refreshToken);
         user.RefreshTokenExpiryTime = DateTime.UtcNow
                        .AddDays(_jwtConfig.RefreshTokenValidityInDays);

         await _userDbContext.SaveChangesAsync();

         return new TokenResponse
         {
             AuthToken = authToken,
             RefreshToken = refreshToken
         };

     }
}

How TDD Improves Code Structure (Does This All Make Sense)?

Yes, it does. If applied properly, the following structural benefits can become more apparent:

  • Loose Coupling: Since tests require isolating components, TDD encourages designing classes that don’t depend too heavily on each other.

  • Dependency Injection: Writing testable code often means injecting dependencies instead of hardcoding them, leading to better modularity. More dependency injection examples can be found in Dependency Injection With Strategy Pattern.

  • Composition Over Inheritance: Rather than relying too much on inheritance, TDD can encourage a composition-based approach, making code more flexible. More on this topic can be found in Composition over Inheritance.

  • Separation of Concerns: Following the TDD cycle helps ensure each class has a single responsibility, making it easier to maintain and extend.

  • Improved Testability: The necessity of testing first forces developers to create well-structured, reusable components that are easier to test in isolation.

Wrapping Up

Sticking to the AAA (Arrange-Act-Assert) pattern keeps tests clean and readable. Tests are also part of the code, so refactor them when needed and do not leave messy code there. It should be treated similarly to the application code.

Make tests independent by not letting them rely on external data or state. A unit shall be isolated from everything else and be focused on one thing. Use mocking frameworks like Moq to isolate dependencies.

Keep tests fast and focused by making each test check just one thing. Test execution speed is very important, because it influences development speed and CI pipeline.

Unit testing (especially with TDD) makes your code more reliable, easier to maintain, and less prone to bugs. Since each test case describes a specific use case, they form a documentation for how your code is expected to behave. Additionally, applying TDD properly leads to better code structure through dependency injection, loose coupling, and composition-based patterns.

Want to check the complete implementation in the real application? The article focuses just on some parts of the implementation that are useful for understanding. The entire source code with the real usage in a .NET Core Web API can be found on OptimalCoder Blueprint GitHub Repository