According to the blog of our company, one may get the impression that we are only engaged in data mining and networks. Therefore, I, as a representative of the developer shop, could not deny myself the pleasure of writing an article about how unit-testing and the division of code into modules are organized in our front end.
A little about yourself
I am engaged in the company Ivi.ru frontend-development. We use the same API as mobile applications, so the implementation of all the basic logic of behavior and display rests on the client side. If we consider that we have quite a lot of screens, we have a rather large code base, the quality of which needs to be monitored somehow. Therefore, we are actively practicing TDD. Well, and since we are all OOP maniacs, tests are organized in accordance with strict object-oriented canons.
About the pain we experienced in organizing the unit-tests, and how we managed to cope with it and we’ll go further.
')
Some theory
NB! Hereinafter, the words “module”, “class” and “subsystem” are used interchangeably, although in reality this is not always the case.
Module connectivity
In the design of software, you can often find two characteristics that describe the quality of code splitting into modules — Coupling and Cohesion. Usually they talk about the principles of "Low Coupling" and "High Cohesion". So what does this mean?
- Low Coupling , or low pairing , means that the application module is minimally dependent on others and is only aware of the functionality it needs. This means that with the right design, with a change in one module, we will not have to edit others or these changes will be minimal.
- High Cohesion , or high connectivity, says that inside the module, all functionality is coordinated and focused on solving some narrow problem. This means that with proper design, the modules are compact and clear, do not contain “extra” code and side effects.
Unit testing
Unit testing is testing of individual modules of the system according to the black box principle. That is, a class or set of classes responsible for a certain function is taken, test data is input to it, and the result of the work is compared with the reference one.
To implement Unit-tests, instead of real external dependencies of the module, so-called mock-objects are used, that is, objects that replace the “real” functionality with a test one.
Often used techniques (
TDD ,
BDD ), in which tests are first written on a code that does not yet exist, and then the module itself that implements the tested functionality. This is useful not only from the point of view of test coverage, but also from the point of view of the correct architectural organization of the modules, because first we design the external interfaces of the “black box” and then plunge into the implementation.
Many architectural errors can be identified at the stage of writing tests, because, with a high degree of probability, if the code is convenient to test, then it just has a low pairing and high connectivity. If the code under test has a high conjugation, then the implementation of the tests will result in complex, logic-rich mock objects, and if low connectivity, then many similar or complex cases and combinations of input and output data.
Lot of practice
The main problem that we will solve in this article is the question of how to organize the code in such a way that the unit testing is simple and the code is accurate.
Examples are given in TypeScript, but the approach is valid for any strongly typed object-oriented language (Java, C ++, ObjC).
So, we will consider the elementary applied task:
Suppose we have a helloworld-class A. Its code looks like this:
class A { greeting(): string { return 'Hello, ' + this.b.getName() + '!'; } private b: B = new B(); }
As you can see, this class has an external dependency - B.
class B { getName(): string { return 'Habr'; } }
Our task is to cover all the functionality of class A with tests.
Testing all
The simplest method is “head on”, that is, to test all logic at once:
it('test', ()=>{ var a: A = new A(); expect(a.greeting()).toBe('Hello, Habr!'); });
The pros and cons of this approach are quite obvious:
- + Such code is easy to write.
- + It is convenient in cases when there are few tests in the project and they are used for catching complex bugs.
- - It is not the class A itself that is being tested, but a whole layer of functionality. If the reservoir is large and the functionality is complex, the tests will be too voluminous and complicated. By and large, this is not a unit test, but an I & T.
- - If you change the code B, you have to edit all the tests of the modules using it.
- - Such tests do not encourage the developer to properly break the code into modules.
Redefining the method on the fly
“Okay,” you say, “then let's just redefine the field we need and that's it.” For example, like this:
it('test', ()=>{ var a: A = new A(); a['b'] = { getName: ()=>'Test' }; expect(a.greeting()).toBe('Hello, Test!'); });
It would seem that the problem is solved, but no: if the field b is created inside the class dynamically, then we must constantly monitor this and slip our test value. Eventually:
- + No need to test external dependencies.
- - The principle of “black box” is violated - you need to edit the private field of the class.
- - It is necessary to ensure in the test that the replaced field is always relevant, that is, the class implementation itself does not overwrite its value.
- - It is impossible to do this in “real” strictly typed languages.
- - All this does not add readability tests
Inheriting from class under test.
In fact, this is the same method as in the previous example, only adapted for languages ​​with strong typing. First, we make the field b in class A not private, but protected, and create a mock class, a wrapper over A:
class MockA extends A { constructor() { super(); this.b = { getName: ()=>'Test' }; } }
We will test this new class:
it('test', ()=>{ var a: A = new MockA(); expect(a.greeting()).toBe('Hello, Test!'); });
- + Strongly typed version of the previous approach.
- - It did not solve the problem.
Dependency injection
Of course, the problem of dependency management is not new, and its solution exists. You, probably, have already heard about
Dependency Injection , if briefly - this is an approach in which the module does not manage its own dependencies, and they themselves come to it from outside (for example, through the constructor).
In our case, it looks like this:
class A { constructor(private b: B) {} greeting(): string { return 'Hello, ' + this.b.getName() + '!'; } }
Then in the test itself we can wrap class B already:
class MockB extends B { public getName() { return 'Test'; } }
And pass our white wrapper to A:
it('test', ()=>{ var a: A = new A(new MockB()); expect(a.greeting()).toBe('Hello, Test!'); });
- + Testing is honestly conducted according to the “black box” principle.
- + The code is correctly divided into modules.
- - It’s not always convenient to inherit from the real class (more on this later).
Dependency injection using an interface
It is not always easy to extend from a class, and the functionality that is implemented in it can have parasitic (for a given test) side effects. The declaration of the interface of the module that we use as a dependency will help us solve this problem:
interface IB { getName(): string; }
Then instead of inheriting from the real class B, we simply implement its interface:
class MockB implements IB { getName() { return 'Test'; } }
Testing will look the same as in the previous example:
it('test', ()=>{ var a: A = new A(new MockB()); expect(a.greeting()).toBe('Hello, Test!'); });
- + Tests test only one module and depend only on its implementation
- - Works only as long as the project is small and the subsystems are small
We separate interfaces
We proceed directly to the purpose for which this article was started, namely, to the separation of interfaces of one subsystem. In foreign literature, this is sometimes called "Interface Decoupling"
Let's now imagine that we have a large project with a large number of modules. Let class A still use only one method from B, but it and other methods (of which there may be many) actively use other modules. In this case, the IB interface is quite voluminous:
interface IB { getName(): string; getLastName(): string; getBirthDate(): Date; }
Now, in order to make a mock object for class A being tested, we will need to define a few more unnecessary methods:
class MockB implements IB { getName() { return 'Test'; } getLastName():string { return undefined; } getBirthDate():Date { return undefined; } }
Imagine what kind of wall of text we get if the module depends on a couple of other modules with 10+ methods. Moreover, because of this, we get a high pairing, due to the fact that the module "knows" about the methods of another module that it does not use. This leads to the fact that when changing the signature of one of the methods, the code will have to be changed in all tests, and not only in those that use the modified method.
In order to avoid this excessive awareness, we will
separate the interfaces for specific subsystems . Select the sets of methods that each module uses from the IB interface and group them into separate interfaces. In our case, it looks like this:
export interface IBForA { getName(): string; } export interface IBForSomeOtherModule { getLastName(): string; getBirthDate(): Date; }
Combining all of these interfaces and must implement class B:
export interface IB extends IBForA, IBForSomeOtherModule { } class B implements IB { public getName(): string { return 'Habr'; } public getLastName():string { return 'last'; } public getBirthDate():Date { return new Date(); } }
Class A, in turn, does not depend on the entire IB interface, but only on its own:
class A { constructor(private b: IBForA) { } greeting(): string { return 'Hello, ' + this.b.getName() + '!'; } }
Thus, each module for each of its dependencies has an interface that describes what and only that is used in this module.
- + Each module knows only what it needs to know about others.
- + Any local changes of one of the modules will affect only the tests for this module.
- + Changing one of the methods will only change the modules that directly use this interface.
- - A large number of interfaces and macro classes make it difficult to navigate in code.
Instead of conclusion
As always it turns out in practice, it is most convenient to use some kind of hybrid approach. For example, on our project we use interface separation only for large subsystems, and inside them for classes we make mock-objects a simple extend.
In any case, the patterns described make life easier when working on TDD. As I wrote above, properly organized tests help identify the architectural problem
prior to its implementation , and this is saved man-hours of developers and nerves of managers.
All the examples described here can be viewed on github .
Many thanks to darkartur for help in writing this article.