arrow
Back to blog

.NET Unit Testing: Tools to Get It Right

clock

12 min read

The necessity of unit testing in software development ceased to be the subject of discussion lately: the real-life practice has proved, that unit tests are essential for a stable product. But speaking about the necessity of writing unit tests, authors often forget to explain how to do this. At the same time, many developers don’t give pride of place to unit tests as a part of their job. The common approach is when developers simply create a test project using a basic testing framework only and then use this project to solve the same tasks again and again. Such an approach is not time efficient because testing consumes more time than coding. The outcome is predictable and always the same: developers refuse writing tests to meet the next strict deadline, or testing turns into a tedious ritual.

Our engineers in Dashdevs is always eager to investigate an alternative to a common approach. This article is a how-to for writing tests fast and efficient with examples relevant for .NET platforms.

Testing frameworks

A testing framework is the core of a test project, so it is very important to make the right choice, because it is hard to migrate between testing frameworks. Frankly speaking, there is nothing special to choose because testing frameworks usually have similar functionality. So, you need to pay attention to the next few points:

  1. Compatibility with a development environment. First of all, look whether an environment is able to run tests and display their results correctly. Check out if a selected testing framework has a test-adapter available for your development environment.
  2. Test management functionality. Make sure a testing framework offers you features to manage tests that you require as a configuration of parallel running, setting the order of execution, tests grouping, test initialization, etc.
  3. Support of test cases (hereinafter theories). Theories simplify writing unit tests of the same type. An ideal framework should offer a feature of keeping theories as a separate class or file.

Let us highlight several details at this point. Though most of the test frameworks support theories, many developers do not use them.

Suppose you need to test the following method:

public bool Authorize(int userId, string role)
{
    if (userId <= 0)
    {
		throw new ArgumentException(nameof(userId));
    }
    
    if (string.IsNullOrWhiteSpace(role))
    {
        throw new ArgumentException(nameof(role));
    }
    
        ...
}

There are five ways of execution in this method: four exceptions (userId < 0, userId == 0, role == null и role == “”) and one successful execution. So a lot of developers write 5 different tests: one for each execution branch. Let us highlight that the examples in the article are relevant for .NET.

Exceptions tests may look like this:

[TestMethod]
public void Authorize_UserIs0_InvalidUserException()
{
    // Arrange
    var userDao = new Mock();
    var example = new Example(userDao.Object);
    
    // Act
    Action act = () => example.Authorize(0, "roleName");
    
    // Assert
    Assert.ThrowsException(act, "userId");
}

[TestMethod]
public void Authorize_UserIsLessThen0_InvalidUserException()
{
    // Arrange
    var userDao = new Mock();
    var example = new Example(userDao.Object);
    
    // Act
    Action act = () => example.Authorize(-1, "roleName");
    
    // Assert
    Assert.ThrowsException(act, "userId");
}

[TestMethod]
public void Authorize_RoleIsNull_InvalidRoleException()
{
    // Arrange
    var userDao = new Mock();
    var example = new Example(userDao.Object);
    
    // Act
    Action act = () => example.Authorize(1, null);
    
    // Assert
    Assert.ThrowsException(act, "role");
}

[TestMethod]
public void Authorize_RoleIsEmpty_InvalidRoleException ()
{
    // Arrange
    var userDao = new Mock();
    var example = new Example(userDao.Object);
    
    // Act
    Action act = () => example.Authorize(1, "");
    
    // Assert
    Assert.ThrowsException(act, "role");
}

Such an approach is not that elegant because it conflicts with the DRY principle. Whereas the violation of DRY principle may cause an increase in the scope of work in case of refactoring or changing a code. We could carry out the repeated parts of a code into the private method and call it four times, yet theories offer a bit more elegant approach to testing:

[TestMethod]
[DataRow(0, "roleName", "userId", DisplayName = "UserId is 0")]
[DataRow(-1, "roleName", "userId", DisplayName = "UserId is -1")]
[DataRow(1, null, "userId", DisplayName = "Role is null")]
[DataRow(1, "", "userId", DisplayName = "Role is empty")]
public void Authorize_ArgumentException(int userId, string role, string message)
{

    // Arrange
    var userDao = new Mock();
    var example = new Example(userDao.Object);
    
    // Act
    Action act = () => example.Authorize(userId, role);
    
    // Assert
    Assert.ThrowsException(act, message);
}

Theories make it possible to unite similar tests into one. Working with test cases, you need to pay attention to the following two things:

  1. As tests arguments, we send method arguments along with results. In the example above, it is an error message. Besides, it is possible to send the expected number of method calls or the result of method execution.
  2. Each theory may contain comments. It is not necessary to add comments, but highly desirable, because these comments will be displayed in the test-explorer. If there are no comments specified, the test arguments will be displayed instead.

In the described case, MSTest was used as a testing framework. It features theories that satisfy many requirements, except for the limitations of C# attributes. In particular, you cannot pass a reference type or delegate as an attribute. Another inconvenience is a test with a big number of arguments. The reason is that the signature of a method, containing more than five arguments, is hardly readable. If you are comfortable with the mentioned above disadvantages — basic MSTest may fit you. Otherwise, you should choose a more powerful alternative like nUnit, which allows declaring theories as separate classes.

Assertion frameworks

The basic functionality for assertions is provided by test frameworks. Unfortunately, such functionality usually is limited and constrained. For example, MSTest provides the following construction to check assertions:

Assert.AreEqual(val, "Value");

There is nothing wrong with this construction, but it is hardly readable. An alternative assertion library Fluent Assertion provides much more convenient syntax:

val.Should().Be("Value");

Though the syntax is a matter of personal preference, there are also non-biased factors to help you with a choice of an assertion framework. Among useful features of such a framework are the following:

  1. Support of arrays checks;
  2. Support of exceptions checks;
  3. Support of checking classes by fields;
  4. Support of checking different classes with the same fields;
  5. Support of checking private members of classes.

Most of the test frameworks allow you to run the listed functionality in one way or another. However, workarounds can be extremely time-consuming. The possibility to compare several class arrays by the single string is priceless.

Mock frameworks

Test frameworks usually do not include mockers, despite the fact that writing unit tests without the mocking — is a very complicated task. Without mocking test, you will be forced to write a test-version of each class (later on — mock). Moreover, in some cases, mock tests must include not only empty methods but statistical functionality as well. In particular when there is a need to count method calls, save method call conditions (context, arguments), etc. Such classes creation takes a lot of time and clutters the code. You may use a mocking framework instead, the main task of which is the dynamic creation of stubs of a class or interface.

As soon as a mocking framework offers a wide range of features and is a core part of a testing project, that is why it requires a serious consideration while choosing it. The factors listed below may help you to make the right decision on a mock framework:

1. Behavior customization. All mocking solutions allow customizing mocks behavior to some extent, but the complexity and variety of settings differ greatly. A required mocker should offer settings of custom method behavior for specific arguments. You should also take into account the availability of a partial mocking feature. Partial mocking allows creating mocks, that inherit prototypes’ behavior. You may need mentioned actions because of mistakes in design, and even if you do not make such mistakes yourself — it may be necessary to cover other developers’ code with tests.

2. Volume and availability of statistics. Many mocking frameworks collect statistic of method’s calls for mocks, but the amount of the statistic differs. The minimal statistic that a mocker should count is a number of method’s calls with specific arguments. In addition, you’d better check statistics availability, because it is usually stored in complicated data formats, so getting a specific value out of a report can be a difficult task.

3. Assertions. Most of the mockers have their own assertions solutions (based on mock statistics). It protects you from receiving statistics from a mock directly.

In Moq library, for example, assertions looks the next way:

userDao.Verify(x => x.Process(It.Is(userId)), Times.Exactly(1));

In nSubstitute the same check looks like the following:

userDao.Received(1).Process(Arg.Is(x => x == userId));

Moq and nSubstitute are powerful mocking libraries, and they differ mostly in the syntax. Anyway, if you have chosen a less popular solution for some reason, you need to ensure that a specified mocker provides you with simple access to a statistic or have built-in assertions.

4. Mocking of static type classes. The author prefers not to use static type objects at all, but not all developers share my opinion. So you may need to mock static objects while testing not-yours code. Unfortunately, such a function is rarely available in mocking libraries (for example, Moq doesn’t support it). The necessity of mocking static classes depends on their amount in a project. If the number of static classes is not big (which is a sign of a proper design), you will not need such a function.

Fixture generators

A fixture generator (hereinafter autofixture) is quite a stranger in test projects, despite the fact that it may save a lot of time. Usually an Arrange area occupies a big part of a test text, and most of such “Arranges” are used for creation of testing data (hereinafter fixture). Refer a sample:

[TestMethod]
public void Process_Success()
{
    // Arrange
    var user = new User()
    {
        FirstName = "John",
        LastName = "Doe",
        Address = "Gotham",
        Age = 21,
        Phone = 1234567890,
        RegistrationDate = new DateTime(2019, 1, 1),
        IsProcessed = false
    };
    
    var userDao = new UserDao();
    
    // Act
    userDao.Process(user);
 
    // Assert
    user.IsProcessed.Should().BeTrue();
}

Creating fixtures manually is a tedious and monotonous process, which, in addition, can be a source of extra mistakes (especially, if you use copy-paste to create fixtures). You can avoid a routine by delegating fixtures creation to a fixture generator. For the sample below we use the AutoFixtures library:

public void Process_Success()
{
    // Arrange
    var fixture = new Fixture();
    var user = fixture.Create();
    var userDao = new UserDao();
 
    // Act
    userDao.Process(user);
    // Assert
    user.IsProcessed.Should().BeTrue();
}

When choosing an autofixture, you need to focus on the availability of the following functionality:

1. Customization of fixture generation. For example, you would like to set IsProcessed as false, and you do not need RegistrationDate at all. In AutoFixture you can set this the following way:

var user = fixture.Build()
	.With(x => x.IsProcessed, false)
	.Without(x => x.RegistrationDate)
	.Create();

Ideal autofixture should be able to save the setting for different classes into profiles. Such profiles allow omitting the mentioned-above construction every time when you need to create a fixture of User.

fixture.Customize(composer => composer
.With(x => x.IsProcessed, false)
.Without(x => x.RegistrationDate));

2. Customization of an instantiation type. A constructor featured validation or inner logic may be a serious problem for an autofixture. The same is true for classes instantiated by the factory only. A fixture generator must support different strategies of type constructing, and, same as in the previous case, allow to saving constructing settings as profiles for various types. For example, in AutoFixture customization of construction looks like:

fixture.Register(() => new User());

3. Integration with a testing framework. n the examples above, MSTest and AutoFixture are used, the tools that do not have any integrations. But, if we replace MSTest by nUnit, test Process_Success will look like:

[Test, AutoData]
public void Process_Success(User user)
{
	// Arrange
	var userDao = new UserDao();
	
    // Act
 	userDao.Process(user);
 
 	// Assert
 	user.IsProcessed.Should().BeTrue();
 }

4. Integration with a mocker. The functionality of a mocker and autofixture is pretty similar to some extent. The integration of these tools allows creating mocks, that, from one side, collect statistics of calls, and, from the other, are filled with random values. Below is a sample of an attempt to generate a fixture based on the interface:

var user = fixture.Create();

It will result in Exception, unless you precofigure IUser construction first:

fixture.Register(() => new User());

If a mocker and autofixture are mutually integrated, an autofixture will use a mocker to instantiate an object, and then fill it with random values. Moq integrates with AutoFixture the following way:

var fixture = new Fixture().Customize(new AutoMoqCustomization());

General tips

Many developers consider that unit test projects should not contain anything else apart from test classes. The DRY principle is fully applicable to tests, so this statement is not valid. Making a long story short, if tests include a repeated code — this code should be moved out to a separate method. A high-quality test project includes the development of its own toolkit that simplifies test preparation (Arrange) and test check (Assert). Assertions, mockers, and autofixtures streamline the testing process for you, but still, there will be situations, when their functionality is not enough.

Entities of the same level are often similar, so their tests are similar as well. If that is your case, you should create a generic test for some classes of entities. Moreover, generic tests may contain instances of an autofixture and mocker.

The proper organization of the files in the test project is also crucial for efficiency. There are 2 approaches:

1. The structure of a unit test project coincides with the structure of a real project. Classes are replaced with their tests named the same as a class and adding names of tests in a unit-testing project. The advantage of this approach is extreme clarity, while the disadvantage is a huge test file for classes containing a big amount of complicated methods. In the case of similar project structures, an engineer should pay attention to the names of test methods. The name of a test method should be compound from a method name, test description, and an expected result. Refer to an example (in the article, we focus on .NET cases):

GetUser_ValidId_Success
GetUser_InvalidId_ArgumentExceptionThrown

Names of test methods are displayed in a test explorer, so coherent test names are helpful to define which exactly test fails quickly, without opening it.

2. The structure of directories of test project coincides with the structure of a real project. In test projects, classes are replaced with directories having the same name as tested classes. Inside each directory, there are test classes for each method named after this method and adding a test. To the author’s point of view, this approach is the best because it suits projects of any complexity.

Finally, an engineer shouldn’t consider the world of unit tests as a religion. Covering a project with unit tests, it is vital to concentrate not only on “how much” you’ve covered but “what” covered as well. There are three types of methods, that should be tested.

  1. Methods with branching. Operators if, switch and a ternary conditional operator ?: are categorical arguments for coverage of a method with unit tests.
  2. Methods with calculations. Complex formulas or algorithms (even without branching) are sufficient reasons to test such a method.
  3. Potentially dangerous methods. If you suggest that a method may generate exceptions under certain conditions — you should cover it with unit tests simply to check your concerns.

In case of limits or constraints in time or scope for testing, you may need to prioritize your testing procedures or even exclude some. Methods that haven’t branches, algorithms, or potential risks should not be tested. You shouldn’t write tests on methods of a controller if their only function is to transfer requests to a service. You shouldn’t write tests on third-party packages, let it be the responsibility of their authors. One hundred percent test coverage is a noble aim, yet it is not a vital necessity.

Conclusions

Unit tests are an essential part of a project as significant as an executable code they cover. Though there are situations when developers write clean, self-documented and weakly coupled code, but cover it with clumsy, primitive tests, which devalue their work, are not that rare. We hope this article will help you to develop the right approach to unit testing and protect you from making the same mistakes again and again. We, in Dashdevs, value the think-before-you-code approach and know the power of knowledge and experience, that is why we deliver robust business solutions that have been tested in different ways, including unit testing.

Want to share your thoughts on unit testing or learn more about our experience, go to our site www.dashdevs.com.

Share article

Table of contents