Badoo is a dating service that is available as a website and mobile apps for the main platforms. At the beginning of last year, we globally reworked the site, as a result of which it became a “fat client” and began to work in the same way as mobile applications: call commands on the server and receive answers from it according to the protocol describing the interaction between the client and server parts. These two parts are made by different developers, and, as a rule, the client part is done after the server is ready. At the same time, there is a problem: how can a developer of a new feature verify that the server part works correctly if there is no client for it yet and there is nothing to check it with?
To solve this problem in any server task, we must have written integration tests, which I will discuss in this article.
In our case, these tests are a superstructure over PHPUnit, thanks to which the test becomes a client application that accesses the server using a protocol. At the same time, it is possible to configure which server we want to access. It may be:
In the first case, both the client and the server work within the framework of one PHP process, and in the rest it will be a full-fledged client server when the test sends requests to other servers.
Here is an example of a similar test that checks that a user who has presented a gift to another user will see this gift in his profile:
class ServerGetUserGiftsTest extends BmaFunctionalTestCase { public function testGiftsSending() { // Given $ClientGiftSender = $this->getLoginedConnection( \BmaFunctionalConfig::USER_TYPE_NEW, [ 'app_build' => 'Android', 'supported_features' => [ \Mobile\Proto\Enum\FeatureType::ALLOW_GIFTS, ], ] ); $ClientGiftReceiver = $this->getLoginedConnection(); $gift_type = 1; $gift_add_result = $ClientGiftSender->QaApiClient->addGiftToUser( $ClientGiftReceiver->getUserId(), $ClientGiftSender->getUserId(), $gift_type ); $this->assertGiftAddSuccess($gift_add_result, "Precondition failed: cannot add gift from sender to receiver"); // When $Response = $ClientGiftSender->ServerGetUser( [ 'user_id' => $ClientGiftReceiver->getUserId(), 'client_source' => \Mobile\Proto\Enum\ClientSource::OTHER_PROFILE, 'user_field_filter' => [ 'projection' => [\Mobile\Proto\Enum\UserField::RECEIVED_GIFTS], ], ] ); // Then $this->assertResponseHasMessageType(\Mobile\Proto\Enum\MessageType::CLIENT_USER, $Response); $user_received_gifts = $Response->CLIENT_USER['received_gifts']; $this->assertArrayHasKey('gifts', $user_received_gifts, "No gifts list at received_gifts field"); $this->assertCount(1, $user_received_gifts['gifts'], "Unexpected received gifts count"); $gift_info = reset($user_received_gifts['gifts']); $this->assertEquals($ClientGiftSender->getUserId(), $gift_info['from_user_id'], "Wrong from_user_id value"); } }
Let's take this example in parts.
Each test is inherited from the BmaFunctionalTestCase class - a successor to PHPUnit_Framework_TestCase. It implements several auxiliary methods, the main of which is the possibility of obtaining a client object through which you can send requests to the server:
$ClientGiftSender = $this->getLoginedConnection( \BmaFunctionalConfig::USER_TYPE_MALE, [ 'app_build' => 'Android', 'supported_features' => [\Mobile\Proto\Enum\FeatureType::ALLOW_GIFTS], ] );
Here we can "introduce ourselves" with a specific version of the client with our own set of supported features. After performing this method, we have an object that allows you to send requests on behalf of a registered user using a certain application.
This registered user is taken from a special pool of test users . It has a certain number of “clean” users, i.e. they all have the same initial state. When the getLoginedConnection () method is called in a test, one of these users is selected and it is blocked for use by other tests. Blocking is necessary so that we always deal with users in a state known to us. After blocking with this user, any manipulations can be performed, and after the end of the test, a cleaning mechanism is launched that will lead the user to the original “clean” state, and the user will be available again for use in tests. All test users are in the same location, in which there are no real users. Therefore, on the one hand, we are dealing with predictable environments in tests, and on the other hand, real users do not see test ones.
As a rule, we cannot start the scan immediately after receiving the client object: we need to create the environment necessary for the test (in this example, send a gift to another user). We can do this “honestly” by sending requests to the server through a client object, but this is not always possible. In the case of a gift, an “honest” way would be too complicated: we need to replenish the user's account, get a list of available gifts, send it and wait until it is processed by the sending script. All this will complicate the test and increase the time of its development and implementation.
To simplify this, we use an internal tool called QaAPI (my colleague Dmitry Marushchenko already told about him, the presentation and the video can be found on Habré ). It consists of a set of small methods, each of which allows you to perform separate actions on users, bypassing the standard mechanisms or get some information about the user. With it, you can add photos to the user and immediately moderate them, bypassing the queues and checking by moderators; change the values ​​of individual fields in his profile, vote for other users in “Dating”, etc.
In this example, we simply give a gift without replenishing the account and bypassing the queues:
$gift_add_result = $ClientGiftSender->QaApiClient->addGiftToUser( $ClientGiftReceiver->getUserId(), $ClientGiftSender->getUserId(), $gift_type_id ); $this->assertGiftAddSuccess($gift_add_result, "Precondition failed: cannot add gift from sender to receiver");
It is very important to check QaAPI answers , because in case of an error, the user will not be in the same state that we expect to receive, and further checks will be meaningless. If we talk about our example, it would be strange to check the presence of a gift in the profile, if we could not give it.
If we for some reason do not want to “honestly” bring the user to the desired state, then we can use remote mock objects . Unlike local ones, they are one-time (valid only for one team) and permanent (working until the end of the test).
Technically, mock objects are implemented using our other solution, SoftMocks . It is used either directly (at the developer’s site, when the test works within one process), or through a “pad” in the form of memcache (at a remote site). In the second case, during the test, we put the information about the new mock-object into an array of one-time or permanent mock-objects, and before sending the request to the server we combine these two arrays and put them into memcache, from where the server part can take them.
We often use such mock-objects to check tokens when we need to make sure that the text we need comes in the answer. This can be done “honestly”, but it will not be very convenient: the texts can change over time (and it will break the test), plus they can be different in different languages. To avoid these problems, we replace lexemes with some predefined values ​​or even on the way to texts.
In general, the use of mock-objects makes the test faster, because allows you to get rid of one or more remote calls, but adds dependencies on server code and makes them less reliable: they break down more often and more “lie”.
After creating the desired environment, we can send a request to the server and get an answer:
$Response = $ClientGiftSender->ServerGetUser( [ 'user_id' => $ClientGiftReceiver->getUserId(), 'user_field_filter' => [ 'projection' => [\Mobile\Proto\Enum\UserField::RECEIVED_GIFTS], ], ] );
In such tests, the server code represents a black box for us : we do not know what is happening there and which code handles our request. All we can do is verify that the server’s response meets our expectations .
Our protocol allows the server to return different types of responses to the same command. Commands can return a response of different types. For example, almost any command can return an error. For this reason, we start checking the response with whether there is a message of the expected type:
$this->assertResponseHasMessageType(\Mobile\Proto\Enum\MessageType::CLIENT_USER, $Response);
After we are sure that the message is available, you can check the answer in more detail and make sure that it contains our gift:
$user_received_gifts = $Response->CLIENT_USER['received_gifts']; $this->assertArrayHasKey('gifts', $user_received_gifts, "No gifts list at received_gifts field"); $this->assertCount(1, $user_received_gifts['gifts'], "Unexpected received gifts count"); $gift_info = reset($user_received_gifts['gifts']); $this->assertEquals($ClientGiftSender->getUserId(), $gift_info['from_user_id'], "Wrong from_user_id value");
For commands that modify the user state, it is not enough to check the server response . For example, if we send a command to delete a gift, then it’s not enough to get Success in response - you also need to check that the gift is really deleted. To do this, you can either call other commands and check their answers, or use the same QaAPI by calling the method that returns the state of the parameter that we want to check. In the example of deleting a gift, we could call the QaAPI method that returns a list of gifts and check that it does not contain the one just deleted.
The main advantage of such tests is the understanding that the new functionality works as we expect. If we described the script in the form of such a test and it passed, then we understand that all the functionality works and can be used by a real client application .
Another important plus: we can conduct regression testing and make sure that the changes made will not break the old customers for whom the new functionality will not be available. These tests allow us to do this by specifying different versions of the application (this is the old path that we used for versioning earlier) and a certain set of features supported by the client (this is the new path that we use now).
The main disadvantages of these tests are the long running time and instability resulting from their high level. Although tests usually check the results of a single protocol team, they create a full-fledged environment that works with the same databases and services as regular customers. All this, as well as the “honest” reconstruction of the environment, which requires other requests (often not one or two) to the server, takes time.
Some features require complex initialization , which increases the size of test methods. After all, before calling the tested method, you need not only to send requests for initialization, but also to check that they have worked as you expected. For example, if you want to check the work of the chat, then you need to get two clients, give them the opportunity to "chat" with each other, send a message and check that it really went. It happens that some things happen with a delay and you need to wait for the delivery of data.
Because of this complexity, tests become very “fragile”: a breakdown in recreating the environment will break your test , and although the problem does not apply to what you are testing, your test falls. Such tests will not tell you what exactly is broken, you just realize that something is not working. The specific method, the change of which broke the test, will have to be looked for independently, and sometimes it can be very difficult to do this.
Despite the listed disadvantages, these tests solve their problems and allow developers to write tests in the same form as the unit tests that are familiar to everyone.
Victor Pryazhnikov, Developer, Features
Source: https://habr.com/ru/post/311218/
All Articles