📜 ⬆️ ⬇️

Separate interfaces for unit testing

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?


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:


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:


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!'); }); 


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!'); }); 


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!'); }); 


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.


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.

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


All Articles