Something is wrong with testing in .NET (Java, etc.)

Everyone wants to write tests, but few do. In my opinion, the reason is in the existing recommendations and practices. Most of the effort when testing business applications is applied to working with the database, this is an important part of the system, which is very closely related to the main code. There are two fundamentally different approaches: abstracting the logic from the database or preparing a real database for each test.

If your programming language is strongly typed and has interfaces in it - almost certainly you will work with abstractions. In dynamic languages, developers prefer to work with a real base.

There are interfaces in .net, which means the choice is obvious. I took an example from Mark Siman’s wonderful book, Deploying Dependencies in .Net, to show some of the problems that this approach has.

You need to display a simple list of recommended products, if the list is viewed by a privileged user, then the price of all products should be reduced by 5 percent.

We implement in the simplest way:

public class ProductService
{
        private readonly DatabaseContext _db = new DatabaseContext();
        public List GetFeaturedProducts(bool isCustomerPreffered)
        {
            var discount = isCustomerPreffered ? 0.95m : 1;
            var products = _db.Products.Where(x => x.IsFeatured);
            return products.Select(p => new Product
            {
                Id = p.Id,
                Name = p.Name,
                UnitPrice = p.UnitPrice * discount
            }).ToList();
        }
}

To test this method, you need to remove the dependency on the database - create an interface and a repository:

public interface IProductRepository
{
    IEnumerable GetFeaturedProducts();
}
public class ProductRepository : IProductRepository
{
    private readonly DatabaseContext _db = new DatabaseContext();
    public IEnumerable GetFeaturedProducts()
    {
        return _db.Products.Where(x => x.IsFeatured);
    }
}

Change the service so that it uses them:

public class ProductService
{
    IProductRepository _productRepository;
    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }
    public List GetFeaturedProducts(bool isCustomerPreffered)
    {
        var discount = isCustomerPreffered ? 0.95m : 1;
        var products = _productRepository.GetFeaturedProducts();
        return products.Select(p => new Product
        {
            Id = p.Id,
            Name = p.Name,
            UnitPrice = p.UnitPrice * discount
        }).ToList();
    }
}

Everything is ready for writing a test. We use mock to create a test script and verify that everything works as expected:

[Test]
public void IsPrefferedUserGetDiscount()
{
    var mock = new Mock();
    mock.Setup(f => f.GetFeaturedProducts()).Returns(new[] {
        new Product { Id = 1, Name = "Pen", IsFeatured = true, UnitPrice = 50}
    });
    var service = new ProductService(mock.Object);
    var products = service.GetFeaturedProducts(true);
    Assert.AreEqual(47.5, products.First().UnitPrice);
}

It just looks great, what's wrong? In my opinion ... almost everything.

Complexity and separation of logic


Even such a simple example has become more complicated and divided into two parts. But these parts are very closely related and this separation only increases the cognitive load when reading and debugging code.

Many entities and laboriousness


This approach generates a large number of additional entities that appeared only due to the approach to tests. In addition, it is rather laborious, both when writing new code, and when trying to test existing code.

Dependency injection


A positive side effect was a reduction in code connectivity and improved architecture. Actually, most likely not. All actions were dictated by the desire to get rid of the database, and not by improving the architecture and comprehensibility of the code. Since the database is very strongly connected with logic, I’m not sure that this will lead to an improvement in the architecture. This is a real cargo cult - add interfaces and assume that the architecture has improved.

Only half tested


This is the most serious problem - the repository has not been tested. All tests pass, but the application may not work correctly (due to foreign keys, triggers or errors in the repositories themselves). That is, you also need to write tests for repositories? Is there too much fuss already, for the sake of one method? In addition, the repository will still have to be abstracted from the real database, and everything that we check is good, it works with the ORM library.

Mock


They look great while everything is simple, they look awful when everything is complicated. If the code is complex and looks awful, no one will support it. If you do not support tests, then you do not have tests.

Preparing the test environment is the most important part and should be simple, straightforward, and easy to maintain.

Abstractions flow


If you hid your ORM behind the interface, then on the one hand, it does not use all its capabilities, and on the other, its capabilities can leak and play a trick. This applies to loading related models, maintaining context ... etc.

As you can see, there are quite a few problems with this approach. What about the second, with a real base? I think it is much better.

We do not change the initial implementation of ProductService. The test framework for each test provides a clean database into which you need to insert the data necessary to verify the service:

[Test]
public void IsPrefferedUserGetDiscount()
{
    using (var db = new DatabaseContext())
    {
        db.Products.Add(new Product { Id = 1, Name = "Pen", IsFeatured = true, UnitPrice = 50});
        db.SaveChanges();
    };
    var products = new ProductService().GetFeaturedProducts(true);
    Assert.AreEqual(47.5, products.First().UnitPrice);
}

There are no moxas, there is no separation of logic, and work with a real database has been tested. This approach is much more convenient and understandable, and there is more confidence in such tests that everything actually works as it should.

However, there is a small problem. This system has many dependencies in the tables, you need to fill in several other tables only to insert one row in the Products. For example, Products may require a Manufacturer, and he, in turn, Country.

There is a solution for this: the initial “fixtures” are text files (most often in json) containing the initial minimum data set. A big minus of this solution is the need to maintain these files manually (changes in the data structure, the connection of the initial data with each other and with the test code).

With the right approach, testing with a real base is much easier than abstracting. And most importantly, the service code is simplified, less boilerplate code is needed. In the next article, I will tell you how we organized a test framework and applied several improvements (for example, to fixtures).

Also popular now: