Unit Testing [50]

Posted by joecooper on Sat, 29 Jan 2022 02:53:57 +0100

10.4 reuse code in test section

  integration testing will soon become too large and lose its advantage in maintainability indicators. It's important to keep integration tests as short as possible, but don't let them couple with each other or affect readability. Even the shortest tests should not be interdependent. They should also retain the full context of the test scenario and should not require you to examine different parts of the test class to see what happened.
  the best way to shorten integration is to extract technical and business independent parts into private methods or auxiliary classes. As a side benefit, you can reuse these bits. In this section, I'll show you how to shorten all three parts of the test: scheduling, actions, and assertions.

10.4.1 code of reuse arrangement

  the following list shows what our integration test looks like after providing a separate database context (unit of work) for each part.

Listing 10.7 integration test of three database contexts

[Fact] 
public void Changing_email_from_corporate_to_non_corporate() { 
	// Arrange
    User user;
    using(var context = new CrmContext(ConnectionString)) {
        var userRepository = new UserRepository(context);
        var companyRepository = new CompanyRepository(context);
        user = new User(0, "user@mycorp.com", UserType.Employee, false);
        userRepository.SaveUser(user);
        var company = new Company("mycorp.com", 1);
        companyRepository.SaveCompany(company);
        context.SaveChanges();
    }
    var busSpy = new BusSpy();
    var messageBus = new MessageBus(busSpy);
    var loggerMock = new Mock < IDomainLogger > ();
    string result;
    using(var context = new CrmContext(ConnectionString)) {
        var sut = new UserController(context, messageBus, loggerMock.Object); 
        // Act
        result = sut.ChangeEmail(user.UserId, "new@gmail.com");
    } 
    // Assert
    Assert.Equal("OK", result);
    using(var context = new CrmContext(ConnectionString)) {
        var userRepository = new UserRepository(context);
        var companyRepository = new CompanyRepository(context);
        User userFromDb = userRepository.GetUserById(user.UserId);
        Assert.Equal("new@gmail.com", userFromDb.Email);
        Assert.Equal(UserType.Customer, userFromDb.Type);
        Company companyFromDb = companyRepository.GetCompany();
        Assert.Equal(0, companyFromDb.NumberOfEmployees);
        busSpy.ShouldSendNumberOfMessages(1).WithEmailChangedMessage(user.UserId, "new@gmail.com");
        loggerMock.Verify(x => x.UserTypeHasChanged(user.UserId, UserType.Employee, UserType.Customer), Times.Once);
    }
}

  as you may remember from Chapter 3, the best way to reuse code between test scheduling parts is to introduce private factory methods. For example, the following list creates a user.

Listing 10.8 a separate method to create a user

private User CreateUser(string email, UserType type, bool isEmailConfirmed) {
    using(var context = new CrmContext(ConnectionString)) {
        var user = new User(0, email, type, isEmailConfirmed);
        var repository = new UserRepository(context);
        repository.SaveUser(user);
        context.SaveChanges();
        return user;
    }
}

  you can also define default values for method parameters, as shown below.

Listing 10.9 adding default values for the factory

private User CreateUser(
	string email = "user@mycorp.com", 
	UserType type = UserType.Employee, 
	bool isEmailConfirmed = false) {
    	/* ... */ 
    }

  by default, you can optionally specify parameters, which further shortens the test time. Using parameters selectively also highlights which of these parameters are relevant to the test scenario.

Listing 10.10 using factory methods

User user = CreateUser(email: "user@mycorp.com",type: UserType.Employee);

ObjectMother and test data generator
  the patterns shown in lists 10.9 and 10.10 are referred to as the object matrix. ObjectMother is a class or method that helps create a test fixture (the object of the test run).
  there is also a pattern that helps achieve the same goal of reusing code in the scheduling section. Test data generator. It works similar to ObjectMother, but exposes a smooth interface rather than an ordinary method. The following is an example of a test data generator.
User user = new UserBuilder().WithEmail("user@mycorp.com").WithType(UserType.Employee).Build();
  the test data generator slightly improves the readability of the test, but requires too many templates. For this reason, I recommend sticking to "object mother" (at least in C#, you have optional parameters as language features).

Where is the factory method
When you start refining the essence of testing and transferring technical things to factory methods, you are faced with the problem of putting those methods in place. Should they be in the same class as the test? Basic integration test class? Or in a separate helper class?
  it was simple at first. By default, factory methods are placed in the same class. Only when code duplication becomes an important issue will they be moved to separate helper classes. Do not put factory methods in base classes; Keep this class for code that must be run in each test, such as data cleansing.

10.4.2 code of reuse behavior part

  each behavior part in the integration test involves the creation of database transactions or units of work. This is what the behavior section in listing 10.7 looks like now.

string result;
using(var context = new CrmContext(ConnectionString)) {
	var sut = new UserController(context, messageBus, loggerMock.Object); 
	// Act
	result = sut.ChangeEmail(user.UserId, "new@gmail.com");
}

  this part can also be reduced. You can introduce a method to accept a delegate with information about the controller function you need to call. Then, the method will decorate the call of the controller with the context of creating the database, as shown in the following list.

Listing 10.11 decorator method

//Delegate defines a controller function.
private string Execute(Func < UserController, string > func, MessageBus messageBus, IDomainLogger logger) {
    using(var context = new CrmContext(ConnectionString)) {
        var controller = new UserController(context, messageBus, logger);
        return func(controller);
    }
}

  with this decoration method, you can simplify the behavior part of the test into a few lines.

string result = Execute(
	x => x.ChangeEmail(user.UserId, "new@gmail.com"),
	messageBus, loggerMock.Object);

10.4.3 reuse the code of assertion part

  finally, the assertion part can also be shortened. The simplest way is to introduce auxiliary methods similar to CreateUser and CreateCompany, as shown in the following list.

Listing 10.12 data assertion after extracting query logic

User userFromDb = QueryUser(user.UserId);//New help methods
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);
Company companyFromDb = QueryCompany();//New help methods
Assert.Equal(0, companyFromDb.NumberOfEmployees);

  you can go further and create a smooth interface for these data assertions, similar to what you saw in BusSpy in Chapter 9. In C #, the smooth interface above the existing domain class can be implemented by extension method, as shown in the following table.

Listing 10.13 fluent interface for data assertion

public static class UserExternsions {
    public static User ShouldExist(this User user) {
        Assert.NotNull(user);
        return user;
    }
    public static User WithEmail(this User user, string email) {
        Assert.Equal(email, user.Email);
        return user;
    }
}

  with this smooth interface, assertions become easier to read.

User userFromDb = QueryUser(user.UserId);
userFromDb.ShouldExist().WithEmail("new@gmail.com").WithType(UserType.Customer);
Company companyFromDb = QueryCompany();
companyFromDb.ShouldExist().WithNumberOfEmployees(0);

10.4.4 does the test create too many database transactions?

  with all the previous simplifications, integration testing has become more readable and therefore easier to maintain. However, there is one disadvantage: the test now uses a total of five database transactions (work units), compared with only three before, as shown in the following list.

Listing 10.14 integration test after removing all technical problems

public class UserControllerTests: IntegrationTests {
    [Fact] 
    public void Changing_email_from_corporate_to_non_corporate() {
        // Arrange
        User user = CreateUser(email: "user@mycorp.com", type: UserType.Employee);
        CreateCompany("mycorp.com", 1);
        var busSpy = new BusSpy();
        var messageBus = new MessageBus(busSpy);
        var loggerMock = new Mock < IDomainLogger > ();
        // Act
        string result = Execute(x => x.ChangeEmail(user.UserId, "new@gmail.com"), messageBus, loggerMock.Object);
        // Assert
        Assert.Equal("OK", result);
        User userFromDb = QueryUser(user.UserId);
        userFromDb.ShouldExist().WithEmail("new@gmail.com").WithType(UserType.Customer);
        Company companyFromDb = QueryCompany();
        companyFromDb.ShouldExist().WithNumberOfEmployees(0);
        busSpy.ShouldSendNumberOfMessages(1).WithEmailChangedMessage(user.UserId, "new@gmail.com");
        loggerMock.Verify(x => x.UserTypeHasChanged(user.UserId, UserType.Employee, UserType.Customer), Times.Once);
    }
}

  is the increase in the number of database transactions a problem? If so, what can you do? Additional database contexts are a problem to some extent because they make testing slower, but there is nothing to do about it. This is another example of a trade-off between different aspects of a valuable test: this time between rapid feedback and maintainability. In this special case, it is worth exchanging performance for maintainability. The performance degradation should not be so obvious, especially when the database is on the developer's machine. At the same time, the benefits of maintainability are considerable.

Topics: unit testing