⬆️ ⬇️

Why using unit tests is a great investment in a quality architecture.

Understanding the fact that unit tests are not only a tool to combat regression in code, but also an excellent investment in high-quality architecture, was prompted by a topic dedicated to unit testing in one English-language .net community. The author of the topic was called Johnny and he described his first (and last) day in a company engaged in developing software for enterprises in the financial sector. Johnny applied for the job of a unit test developer and was frustrated by the poor quality of the code he was charged with testing. He compared the code he saw with a dump filled with objects that create each other uncontrollably in any places unsuitable for this. He also wrote that he never managed to find abstract data types in the repository, the code consisted exclusively of tightly intertwined implementations, cross-calling each other. Johnny, realizing the uselessness of applying the practice of unit testing in this company, described the situation hiring him to the manager and, refusing further cooperation, finally gave valuable advice from his point of view. He advised to send a team of developers to courses where they could teach them how to instantiate objects correctly and take advantage of abstract data types. I don’t know if the manager followed the board (I think not), but if you’re interested in what Johnny meant and how the use of unit testing practices can affect the quality of your architecture, welcome to Cat, we’ll work it out.



Dependency isolation is the basis of unit testing.


A unit test or unit test is a test that checks the functionality of a module in isolation from its dependencies. The isolation of dependencies is understood as the substitution of real objects with which the module under test interacts with stubs that mimic the correct behavior of their prototypes. Such a substitution allows you to focus on testing a specific module, ignoring the possibility of incorrect behavior of its environment. The need to replace dependencies in the test implies an interesting property. A developer who understands that his code will be used, including in unit tests, is forced to develop, using all the advantages of abstractions, and refactor at the first signs of the appearance of high connectivity.



Example for clarity


Let's try to imagine what a personal message sending module could look like in a system developed by the company from which Johnny escaped. And what would the module look like if the developers applied unit testing. Our module should be able to save a message in the database and, if the user to whom the message was addressed, is in the system, display the message on its screen with a pop-up notification.

')

//     C#.  1. public class MessagingService { public void SendMessage(Guid messageAuthorId, Guid messageRecieverId, string message) { //        new MessagesRepository().SaveMessage(messageAuthorId, messageRecieverId, message); //,     if (UsersService.IsUserOnline(messageRecieverId)) { //  ,     NotificationsService.SendNotificationToUser(messageAuthorId, messageRecieverId, message); } } } 




Let's see what dependencies our module has. In the SendMessage function, the static methods of the NotificationsService objects, UsersService are called, and a MessagesRepository object is created that is responsible for working with the database. The fact that our module interacts with other objects is not a problem. The problem is how this interaction is built, and it is unsuccessful. A direct appeal to the methods of third-party objects has made our module tightly connected with specific implementations. Such interaction has many drawbacks, but the main thing for us is that the MessagingService module has lost the ability to be tested in isolation from the implementations of the NotificationsService, UsersService and the MessagesRepository objects. We really can’t, as part of a unit test, replace these objects with stubs.

Now let's see what the same module would look like if the developer took care of its testability.



 //     C#.  2. public class MessagingService: IMessagingService { private readonly IUserService _userService; private readonly INotificationService _notificationService; private readonly IMessagesRepository _messagesRepository; public MessagingService(IUserService userService, INotificationService notificationService, IMessagesRepository messagesRepository) { _userService = userService; _notificationService = notificationService; _messagesRepository = messagesRepository; } public void AddMessage(Guid messageAuthorId, Guid messageRecieverId, string message) { //        _messagesRepository.SaveMessage(messageAuthorId, messageRecieverId, message); //,     if (_userService.IsUserOnline(messageRecieverId)) { //   _notificationService.SendNotificationToUser(messageAuthorId, messageRecieverId, message); } } } 




This version is already much better. Now the interaction between the objects is not built directly, but through interfaces. We no longer refer to static classes and do not instantiate objects in methods with business logic. And, most importantly, we can now replace all dependencies by passing the test stubs to the constructor. Thus, achieving the testability of our code, we were able to simultaneously improve the architecture of our application. We had to abandon the direct use of implementations in favor of interfaces and we moved the instantiation to the layer above the level. And this is exactly what Johnny wanted.



Writing a test to the message sending module


Test Specifications
Let's define what our test should check.



  • fact of a single call to the IMessageRepository.SaveMessage method
  • the fact of a single call to the INotificationsService.SendNotificationToUser () method, if the IsUserOnline () method stub over the IUsersService object returned true
  • no call to the INotificationsService.SendNotificationToUser () method, if the IsUserOnline () method stub over the IUsersService object returned false


Meeting these three conditions ensures that the implementation of the SendMessage method is correct and free of errors.



Tests
The test is implemented using the Moq isolation framework.

 [TestMethod] public void AddMessage_MessageAdded_SavedOnce() { //Arrange // Guid messageAuthorId = Guid.NewGuid(); //,   Guid recieverId = Guid.NewGuid(); //,     string msg = "message"; //    IsUserOnline  IUserService Mock<IUserService> userServiceStub = new Mock<IUserService>(new MockBehavior()); userServiceStub.Setup(x => x.IsUserOnline(It.IsAny<Guid>())).Returns(true); //  INotificationService  IMessagesRepository Mock<INotificationService> notificationsServiceMoq = new Mock<INotificationService>(); Mock<IMessagesRepository> repositoryMoq = new Mock<IMessagesRepository>(); //  ,         var messagingService = new MessagingService(userServiceStub.Object, notificationsServiceMoq.Object, repositoryMoq.Object); //Act messagingService.AddMessage(messageAuthorId, recieverId, msg); //Assert repositoryMoq.Verify(x => x.SaveMessage(messageAuthorId, recieverId, msg), Times.Once); } [TestMethod] public void AddMessage_MessageSendedToOffnlineUser_NotificationDoesntRecieved() { //Arrange // Guid messageAuthorId = Guid.NewGuid(); //   Guid offlineReciever = Guid.NewGuid(); //,     string msg = "message"; //    IsUserOnline  IUserService Mock<IUserService> userServiceStub = new Mock<IUserService>(new MockBehavior()); userServiceStub.Setup(x => x.IsUserOnline(offlineReciever)).Returns(false); //  INotificationService  IMessagesRepository Mock<INotificationService> notificationsServiceMoq = new Mock<INotificationService>(); Mock<IMessagesRepository> repositoryMoq = new Mock<IMessagesRepository>(); //  ,         var messagingService = new MessagingService(userServiceStub.Object, notificationsServiceMoq.Object, repositoryMoq.Object); //Act messagingService.AddMessage(messageAuthorId, offlineReciever, msg); //Assert notificationsServiceMoq.Verify(x => x.SendNotificationToUser(messageAuthorId, offlineReciever, msg), Times.Never); } [TestMethod] public void AddMessage_MessageSendedToOnlineUser_NotificationRecieved() { //Arrange // Guid messageAuthorId = Guid.NewGuid(); //,   Guid onlineRecieverId = Guid.NewGuid(); //,     string msg = "message"; //    IsUserOnline  IUserService Mock<IUserService> userServiceStub = new Mock<IUserService>(new MockBehavior()); userServiceStub.Setup(x => x.IsUserOnline(onlineRecieverId)).Returns(true); //  INotificationService  IMessagesRepository Mock<INotificationService> notificationsServiceMoq = new Mock<INotificationService>(); Mock<IMessagesRepository> repositoryMoq = new Mock<IMessagesRepository>(); //  ,         var messagingService = new MessagingService(userServiceStub.Object, notificationsServiceMoq.Object, repositoryMoq.Object); //Act messagingService.AddMessage(messageAuthorId, onlineRecieverId, msg); //Assert notificationsServiceMoq.Verify(x => x.SendNotificationToUser(messageAuthorId, onlineRecieverId, msg), Times.Once); } 






Finding the perfect architecture is no good


Unit tests are an excellent test of architecture for low connectivity between modules. But it should be remembered that the design of complex technical systems is always a search for a compromise. There is no ideal architecture; it is impossible to take into account all possible scenarios for the development of an application when designing. The quality of the architecture depends on many parameters, often mutually exclusive to each other. Any design problem can be solved by introducing an additional level of abstraction, apart from the problem of too many levels of abstractions. It should not be viewed as a dogma that the interaction between objects should be built only on the basis of abstractions, the main thing is that the choice made by you is conscious and you understand that the code that allows interaction between implementations becomes less flexible and, as a result, loses the ability to be tested unit tests.

Source: https://habr.com/ru/post/210518/



All Articles