Everyone wants to write tests, but few do. In my opinion the reason is in existing recommendations and practices. Most of the effort in testing business applications is applied to working with the database; this is an important part of the system, which is very closely connected with the main code. There are two fundamentally different approaches: to abstract logic from the database or to prepare a real base for each test.
If your programming language is strictly typed and there are 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 remarkable book “Introducing dependencies into .Net” to show some of the problems that exist in this approach.
It is necessary 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 the easiest way:
')
public class ProductService { private readonly DatabaseContext _db = new DatabaseContext(); public List<Product> 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<Product> GetFeaturedProducts(); } public class ProductRepository : IProductRepository { private readonly DatabaseContext _db = new DatabaseContext(); public IEnumerable<Product> 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<Product> 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. Use the mock to create a test script and check that everything works as expected:
[Test] public void IsPrefferedUserGetDiscount() { var mock = new Mock<IProductRepository>(); 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 looks just great, what is wrong here? In my opinion ... almost everything.
The 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 because of the approach to the tests. In addition, it is quite laborious, both when writing new code, and when trying to test existing code.
Dependency Injection
With a positive side effect, we got a decrease in code connectivity and improved architecture. In fact, most likely not. All actions were dictated by the desire to get rid of the base, and not by improving the architecture and clarity of the code. Since the database is very tightly coupled with logic, I’m not sure that this will lead to a better 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 need to write more tests for repositories? Is it too much messing around for the sake of one method? In addition, the repository will still have to abstract from the real database and everything that we check, as well, it works with the ORM library.
Mock
Looks great while everything is simple, look awful when everything is difficult. If the code is complex and looks terrible, no one will support it. If you do not support tests, then you do not have tests.
Preparing the environment for the tests is the most important part and it should be simple, clear and easily supported.
Abstractions flow
If you hid your ORM for the interface, then on the one hand, it does not use all its capabilities, and on the other hand, its capabilities can leak and play a cruel joke. This applies to loading related models, maintaining context ... etc.
As you can see quite a lot of problems with this approach. And what about the second, with a real base? I think he is much better.
We do not change the initial implementation of the ProductService. The test framework for each test provides a clean database into which you need to insert the data necessary for checking 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 mocks, there is no separation of logic, and work with this 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: initial “fixtures” - text files (most often in json) containing the initial minimum data set. A big disadvantage of this solution is the need to manually maintain these files (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 an order of magnitude easier to abstract. And the most important thing is that the services code is simplified, less superfluous boilerplate code. In the next article, I will tell you how we organized the test framework and applied several improvements (for example, to fixtures).