I think almost everyone has come across
this opinion : writing tests is difficult, all examples of writing tests are given for the simplest cases, but in real life they do not work. I have the impression in recent years that writing tests is very simple, even trivial
* . The author of the comment mentioned above further says that it would be nice to make an example of a complex application and show how to test it. I'll try to do just that.
*) Writing the tests themselves is really elementary. Creating an infrastructure that makes it easy to write tests is a bit more complicated.
I am not a theorist, I am a practitioner (“I am not Pushkin by nature, I am Belinsky” ©). Therefore, I will use some ideas from Test Driven Development, Data Driven Development, Behavior Driven Development, but I cannot always justify my choice. Basically, my argument will sound like this: “It's easier,” or “it's more convenient.” Understanding perfectly well that my recipes are far from being suitable for everyone, I ask you, first, to still think about my arguments, and, second, not to treat this text as a textbook.
')
So, the drum roll:
Difficult application
This application exists in reality, but it is not public, so I can’t show the real code. Scope - PaaS, platform-as-a-service. Specifically - automate the launch of client applications on a certain virtual infrastructure. Will work inside large enterprises. The architecture is fairly standard: the relational database, on top of Hibernate, then the web interface, everything is managed by the Spring framework. On the side, we have two pribluds: one talks to the API of the virtual infrastructure, and the second connects to each virtual machine created via SSH, and it starts something there, resulting in the virtual machine receiving all the necessary software and the necessary pieces of the client application, and these pieces starts up. To be honest, this is the most complex application that I wrote in my life, I think, will go as an example. Language - Java, interspersed with Scala.
Spring and tests. We test business logic using mock tests
It is very easy to test applications on Spring. One of the reasons for using Spring is that it’s easy to test with it. “A rabbit is one who lives in a hole. Nora is where the rabbit lives ”©
Interface:
public interface DeploymentService { Deployment deploy(Application application, DeploymentDescription deploymentDescription); }
Implementation:
@Service public class DeploymentServiceImpl implements DeploymentService { private DeploymentRepository deploymentRepository; private DeploymentMaker deploymentMaker; private VirtualInfrastructureService virtualInfrastructureService; @Autowired public DeploymentServiceImpl(DeploymentRepository deploymentRepository, DeploymentMaker deploymentMaker, VirtualInfrastructureService virtualInfrastructureService) { this.deploymentRepository = deploymentRepository; this.deploymentMaker = deploymentMaker; this.virtualInfrastructureService = virtualInfrastructureService; } public Deployment deploy(Application application, DeploymentDescription deploymentDescription) {
In principle, it is clear what it does. Of course, the real code is a bit more complicated, but not by much. First, we test DeploymentService.deploy () on the forehead using the JMock mock library
public class DeploymentServiceMockTest extends MockObjectTestCase { private DeploymentRepository deploymentRepository = mock(DeploymentRepository.class); private DeploymentMaker deploymentMaker = mock(DeploymentMaker.class); private VirtualInfrastructureService virtualInfrastructureService = mock(VirtualInfrastructureService.class); private DeploymentServiceImpl deploymentService; public void setUp() { deploymentService = new DeploymentServiceImpl(deploymentRepository, deploymentMaker, virtualInfrastructureService); } public void testSuccessfulDeploymentSavedAndVMsLaunched() { final Application app = makeValidApplication(); final DeploymentDescription dd = makeValidDeploymentDescription(); final Deployment deployment = helperMethodToCreateDeployment(); checking(new Expectations(){{ one(deploymentMaker).makeDeployment(app, dd);will(returnValue(deployment)); one(deploymentRepository).save(deployment); one(virtualInfrastructureService).launchVirtualMachines(deployment.getVirtualMachineDescriptors()); }}); deploymentService.deploy(application, deploymentDescription); } public void testExceptionIsTranslated() { final Application app = makeValidApplication(); final DeploymentDescription dd = makeValidDeploymentDescription(); final Deployment deployment = helperMethodToCreateDeployment(); checking(new Expectations(){{ one(deploymentMaker).makeDeployment(app, dd);will(returnValue(deployment)); one(deploymentRepository).save(deployment); one(virtualInfrastructureService).launchVirtualMachines(deployment.getVirtualMachineDescriptors());will(throwException(new VirtualInfrastructureException("error message"))); }}); ` try { deploymentService.deploy(application, deploymentDescription); fail("Expected DeploymentUnsuccsessfullException!"); } catch (DeploymentUnsuccsessfullException e) {
What is important and interesting? First, the mock tests allow us to test isolated methods without thinking about what these methods cause. Secondly, the writing of tests greatly affects the structure of the code. The first variant of the deploy () method, naturally, did not call DeploymentMaker.makeDeployment (), but the same method inside DeploymentServiceImpl. When I started writing a test, I found that at this stage I was not interested in writing tests for all the options that makeDeployment performs. They have nothing to do with the actions in the deploy () method, which simply has to write a new object to the database, and start the process of creating virtual machines. Therefore, I rendered the logic of makeDeployment () into a separate helper class. I will test it in completely different ways, because the state of the application and deploymentDescription objects is important for its operation. In addition, I discovered that after DeploymentMaker has been tested, I can use it in other tests to create test data. By the way, JMock has the ability to do mocks not only for interfaces, but also for instances of objects. To do this, add setImpostorizer (ClassImpostorizer.INSTANCE) to setUp (). I am sure that there is something similar in other mock libraries.
To finish with this service, it remains:
Testing database interaction
As I wrote above, we use Hibernate to write our objects to the database and read them from it. One of the rules for writing good tests is “Don't test libraries.” In this case, this means that we can trust the authors of Hibernate that they have already tested all possible aspects of writing and reading various object graphs. What we need to confirm with tests is the correctness of our mappings. Plus it's nice to write a small number of integration tests, i.e. Run DeploymentService.deploy () on a real base and make sure that there are no problems.
As far as I know, the following method of testing interaction with databases is recommended: each test method works in a transaction, and at the end of the test a rollback is produced. Honestly, I don't like it. The method we use allows you to test more complex database operations that perform multiple transactions. To do this, we use Hypersonic, an SQL-compatible database written in Java and able to work in memory. All of our database tests create a Spring context, which instead of a real PostgreSQL or MySQL uses Hypersonic. Specific details are beyond the scope of this post, want details - write, tell.
An abstract class is created as the basis for all of our tests. In fact, we used
ORMUnit , which stupidly re-
creates the entire database structure before each test. If you use a real database, you can grow old until all the tests pass. But when using Hypersonic, everything happens very quickly. True true!
public class DeploymentRepositoryTest extends HibernatePersistenceTest { @Autorwired private DeploymentRepository deploymentRepository; public void testSaveAndLoad() { Deployment deployment = DeploymentMother.makeSimpleDeployment(); deploymentRepository.add(deployment); Deployment loadedDeployment = deploymentRepository.getById(deployment.getId()); assertDeploymentEquals(deployment, loadedDeployment); } private void assertDeploymentEquals(Deployment expected, Deployment actual) {
Pay attention to DeploymentMother. We have adopted such designations for the helper classes that create entities. Such entities are used for tests. Our DeploymentMother has the following methods: makeSimpleDeployment (), makeMutliVmDeploymentWithMasterSlaveReplication (), makeFailedDeployment (), makeStartedDeploymentWithFailedVM (), and so on. In principle, this is the implementation of one of the DDD options for the poor. Personally, I prefer this approach to reading data from YAML or XML for the same reason that I prefer Scala, rather than Groovy - type checking at compile time. If I change something in my classes (and if there are a sufficient number of tests, refactroing turns from a dangerous perversion into a pleasant occupation), then the compiler immediately shows me what problems will arise in the tests and what I will need to pay attention to.
When working with Hibernate, the fun begins when writing complex queries. We use a very useful library of
Arid pojos (the same author as ORMUnit), which allows us not to write a huge pile of the same type of call request code. For example, to select all deployments that are ready for launch, it is enough to: a) write a query named findDeploymentsReadyToLaunch in the Hibernate mapping, and define the List <Deployment> method FindDeploymentsReadyToLaunch () in the DeploymentRepository interface. And that's all, when you start arid-pojos, it will generate code that will launch this particular query. Again, we are not testing libraries, so we just need to create test data and make sure that what we expect is returned from the database. Add to DeploymentRepositoryTest:
public void testRetrieveReadyToLaunch() { for (int i=0; i<5; i++) { deploymentRepository.add(DeploymentMother.makeReadyToLaunchDeployment()); } deploymentRepository.add(DeploymentMother.makeNewDeployment()); List<Deployment> result = deploymentRepository.findDeploymentsReadyToLaunch(); assertEquals(5, result.size()); for (Deployment d : result) { assertTrue(deployment.isReadyToLaunch()); }
Small digression: problems in previous examples
In principle, the above test samples work fine. What is the problem? The fact that they are not very easy to read. We are trying to ensure that the tests could easily restore the requirements for the project and the code. What can be done to make even such simple tests easier to read? Use intention-revealing method names, i.e. names of methods that reveal intentions (this is already a bit of BDD). For example, to call the test not testSaveAndLoad, but testSavedDeploymentLoadedCorrectly. Not testRetrieveReadyToLaunch, but testOnlyReadyToLaunchDeploymentsRetrieved.
Next, assertEquals (5, result.size ()) - requires a little tense to understand what the programmer wanted to say with this. Instead, it's better to create an assertSize (int expected, Collection collection) method in your TestUtils (do you have TestUtils, right ?!). Or even better:
In TestUtils:
public static void T assertCollection(int expectedSize, ElementAsserter<T> asserter, Collection<T> actual) { assertSize(expectedSize, actual.size()); for (T element : actual) { asserter.assertElement(element); } } public static abstract class ElementAsserter<T> { public void assertElement(T element) { if (!checkElement(element)) fail(getFailureDescription(element)); } protected abstract boolean checkElement(T element);
And then in our test we can do this:
ElementAsserter readyToLaunch = new ElementAsserter<Deployment>() { protected boolean checkElement(Deployment d) { return d.isReadyToLaunch(); } protected String getFailureDescription(Deployment d) { return String.format("Deployment with id %d and name %s is NOT ready to be launched!"); } } private void assertAllReadyToLaunch(int expectedSize, List<Deployment> deployments) { TestUtils.assertCollection(expectedSize, a, deployments); } public void testOnlyReadyToLaunchDeploymentsRetrieved() { for (int i=0; i<5; i++) { deploymentRepository.add(DeploymentMother.makeReadyToLaunchDeployment()); } deploymentRepository.add(DeploymentMother.makeNewDeployment()); assertAllReadyToLaunch(5, deploymentRepository.findDeploymentsReadyToLaunch()); }
You can also hide the creation of five necessary objects in a separate method, so that it becomes quite clear. No limits to perfection. Why all this? And then, that the programmer does not have excuses from writing new tests. If all that is required of him is two lines of code (it is very easy to write tests to call a method to create some objects and determine if some condition is checked). And the process gives you an incomparable pleasure in the realization that your code can be directly run live — everything will work.
So what is next?
That's all for today. If there is interest in habra people, in a couple of days there will be a continuation in which I plan to tell about (our) approach to testing the application entirely, communicating with external services, and answer questions. There will also be more about "complex infrastructure for simple tests."