diff --git a/content/thoughts/reveries-about-testing.md b/content/thoughts/reveries-about-testing.md index d8ea9b9..47ca54e 100644 --- a/content/thoughts/reveries-about-testing.md +++ b/content/thoughts/reveries-about-testing.md @@ -37,3 +37,159 @@ What I want to discuss: 3. The Explosion of Slow Tests; 4. Coverage; 5. Mocks. + +## TDD, Kent Beck Style + +What made me rethink the way I wrote tests was a video by Ian Cooper, called +["TDD, where it all go wrong"](https://vimeo.com/68375232). In this video, +Cooper points out that the Beck's book (which brought the whole TDD +revolution) says two things: + +1. Tests should run in an isolated way, nothing more, nothing less; +2. Avoid testing implementation details, test behaviors. + +The first point is what it means for a "unit test", meaning "run in +isolation", in the sense that the test does not depend on others. This way, +"unit tests" should be seen as "the test is a unit", not "testing units" -- +there are no "units", the test itself is a unit that doesn't depend on +anything else. + +The second point is that one should test behaviors, not the implementation. +This is a point that I see we fail a lot when we talk about testing every +class/function: What if the expected behavior is the combination of two +classes? Is it worth writing tests for both, if splitting the classes (or +functions) is just a matter of implementation/way to simplify the code? + +Also, another question for writing tests for every function and every class: +What we have know an application are their input channels -- which could be a +button in a graphical interface, an option passed in the command line or a web +request -- and the output channels; that way, the behavior is "given that +input in the input channel, I want this in the output channel", and everything +in the middle is implementation. And, for that transformation of something in +the input channel to the output channel, I may need a combination of +classes/functions; if I test every class/function, am I really testing the +expected behavior or the implementation? + +"But this looks like BDD!", you must be thinking. Cooper says this in the +video above: the idea of "test every class/function" became the norm, the +point of checking behavior had to be used somewhere else, what gave us ATDD +(Acceptance-Test Driven Development) and BDD (Behavior Driven Development). + +An example of testing behaviors: In the Django subreddit, there was a +question: [Should I write unit tests for Django's built in types?](https://www.reddit.com/r/django/comments/5bearg/should_i_write_unit_tests_for_djangos_built_in/) +The question is basically this: Django allows defining the database model, and +from that model create a form that I can put on my templates and validate the +incoming data; that way, if I defined that there is a field in my model called +"Year of Birth" -- which can only receive numbers -- and I create a form based +on the model, put it on my template, send the data back and Django will make +sure, from the type in the model, that the incoming request will have a number +in that field. Should I still write a test for that? + +The answer, though, is in take a step back and do the following question: Why +the year is a number? Obviously, 'cause years are defined as numbers[^1] and +the behavior of the field was defined way before we added the field in the +model. Also, supposed that for some reason, I need to store the field as a +string[^2]; if the type changes, the behavior should also change? Probably +not. + +When I ignored that the year should be a number 'cause "the framework will +take care of it", I ignored the expected behavior due the implemtantation. + +And "test behaviors, not the implementation". + +Non-factual, but an anecdote: In a project, we had an "alarm manager" where, +from an event, it should generate only a log entry, generate a log entry and +send a SNMP signal or, if the user set it, generate a log, send a SNMP signal +and turn on a LED in the front of the device. With that, we created a module +for the log, a module for sending SNMP signals and a module to turn on/off the +LEDs. Every module had tests, but we didn't feel comfortable with it yet. +That's when I suggested we write a test that would bring the service up and +send events to it, just like any other application in the system, and check +what would happen. That's when the tests finally made sense. (I'll still talk +about these tests in the coverage part of this post.) + +## Fast Tests, Slow Tests + +The counterpoint of the points above can be something similar to what Gary +Bernhardt says in his presentation [Fast Test, Slow +Test](https://www.youtube.com/watch?v=RAxiiRPHS9k). In it, Bernhardt mentions +that they changed the way the tests work, and that now they could run +hundreds of tests in less than a second (an example shows around 600 tests +being run in 1.5 seconds). + +What Bernhardt suggest is to write tests that checks online the models, with +no connection to the database or the views; tests for controllers with no +connection to the models or views; and tests for the views without the +controllers. + +Does that sound familiar (specially if you use a MVC framework, which puts +each of those layers in different classes)? + +Still about those tests, Bernhardt points that those "quick runs" help the +developers to test their to test their changes quickly (does that still sound +familiar?) but those tests do not replace the "integration tests". + +In that point, I have to ask: If the tests are written to check if a +controller can be run without being connected to the rest of the system, but +one still have to write the (so called) integration tests to verify that the +project is delivering whatever was promised it would deliver, what is really +being tested here? The impression I have from the type of test Bernhardt +proposes is more to check _architectural adherence_ than a quality test: Does +this controller follow the structure of not having any connections to the +database itself? Does this model has only functions related to the storage and +retrieval of data, without any logic? If that's it, what is the value for my +user if a controller doesn't access the database directly? + +It's not that I don't believe those tests have no value, but they give the +impression that, in the long run, they tend to become structurally very +similar while the (so called) integration tests tend to give more returns to +the quality of the project: Tests that defined an input and an expected result +tend to make sure that, in the long run, the functionality of the project will +still be the same. + +## The Explosion of Slow Tests + +The first thing that may pop up with a point like the above is that +"integration tests are slow and that will make the tests slow and make +developers less productive." + +Yes, integration tests are slow, specially 'cause there is a good leg of work +in creating the expected initial state, all the inputs as expected by the +I/O system (again, graphical interface, command line, web), run the whole +processing stack and verify the result. And yes, waiting all this time may end +up breaking the developer's flow. + +On the other hand, when a developer is working with some input, if it is a new +functionality/expected behavior, then there should be a test for this +behavior; if there is a change in the expected behavior, there should be a +test for the old behavior that needs to be changed. Running _just_ this test is +enough? No, but it should give a very good indication if the functionality is +working as expected. After making sure the behavior is correct, the developer +may execute the suite of tests for the thing being changed and let everything +else to the CI. + +For example, if I'm working in a new functionality to show an error message +when there is an invoice when the product is not in stock, I have to write a +test that creates the product, let it with no pieces in stock, make an invoice +and check for the error message. Once this test checks the behavior is +correct, I can run all the other invoice tests, and then let the CI validate +that I didn't break anything else else in the stock management module or even +the customer module (for some reason). + +And plugging with the first point, in order to do all the checks, I'd probably +need lots of functions/classes and test every single one of them will not make +sure the expected behavior is correct, but I'll get back to this later in the +coverage part. + +I have the impression that we don't use this kind of stuff due two different +problems: the first is that testing tools have a very bad interface for +running suite of tests (for example, running all the invoice tests and _only_ +the invoice tests); the second is that developers are lazy, and it's a lot +easier to run all tests than picking a single suite (not to mention organizing +said tests in suites to be run that way). + +## Coverage + +--- + +[^1]: Unless you want to use roman numerals, but anyway... diff --git a/content/thoughts/reveries-about-testing.pt.md b/content/thoughts/reveries-about-testing.pt.md index 5b45f64..6dbea7c 100644 --- a/content/thoughts/reveries-about-testing.pt.md +++ b/content/thoughts/reveries-about-testing.pt.md @@ -44,8 +44,7 @@ Agenda de coisas que eu vou comentar: O que me levou a repensar a forma como eu escrevia testes foi um vídeo do Ian Cooper chamado ["TDD, where it all go wrong"](https://vimeo.com/68375232) ("TDD, aonde é que a coisa deu errado"). No vídeo, Cooper coloca que o livro que -Beck escreveu (que deu origem a toda a revolução do TDD) diz apenas duas -coisas: +Beck escreveu (que deu origem a toda a revolução do TDD) diz duas coisas: 1. Testes devem ser executados de forma isolada, nada mais, nada menos. 2. Evite testar detalhes de implementação, teste comportamentos. @@ -60,15 +59,15 @@ O segundo ponto é que deve ser testado o comportamento, não a implementação. Esse é um ponto que eu vejo falhar um bocado quando pensamos em testar todo e qualquer classe e/ou função: E se o comportamento esperado é a combinação de duas classes? Vale a pena escrever testes para as duas, sendo que a questão de -separar em duas classes diferentes (ou duas funções diferentes) se referem ao -mesmo comportamento? +separar em duas classes diferentes (ou duas funções diferentes) é apenas uma +questão de implementação/simplicidade de código? Ainda, outro questionamento sobre testar todas as funções e todas as classes: o que sabemos de uma aplicação são os canais de entrada -- que pode ser por um botão em uma interface gráfica, um texto digitado na linha de comando ou uma requisição web -- e os canais de saída; assim, o _comportamento_ esperado é "dado essa entrada pelo canal de entrada, quero ter essa saída", e qualquer -coisa no meio é implementação. De novo, para fazer a transformação de uma +coisa no meio é implementação. E para fazer a transformação de uma entrada para uma saída específica, eu posso precisar de mais de uma função e/ou classe; se eu estiver testando cada uma das funções, eu estou realmente testando o comportamento ou a implementação? @@ -80,7 +79,7 @@ formato, o que deu origem ao ATDD (Acceptance-Test Driven Development, Desenvolvimento Guiado por Testes de Aceitação) e BDD (Behaviour Driven Development, Desenvolvimento Guiado por Comportamentos). -Um exemplo de como testar comportamento: No Subreddit do Django, foi criada +Um exemplo de testes de comportamento: No Subreddit do Django, foi criada uma pergunta: [Devo Escrever Testes Para os Tipos Built-In do Django?](https://www.reddit.com/r/django/comments/5bearg/should_i_write_unit_tests_for_djangos_built_in/) A questão se resume ao seguinte: Sabendo que no Django eu tenho tipos @@ -90,20 +89,20 @@ servem para validar os dados de entrada; assim, se eu defini que há um campo no meu banco chamado "Ano de nascimento" -- que só pode receber números inteiros -- e eu crio o formulário a partir do banco, coloco no meu template, recebo os dados de volta e o próprio Django vai garantir, pelo tipo do dado no -banco, que o resultado é um número inteiro, eu ainda preciso escrever um +banco, que o valor do campo é um número inteiro. Eu ainda preciso escrever um teste para isso? -A solução, no entanto, é dar um passo atrás e fazer a seguinte pergunta: _Por -que_ o ano é um inteiro? Obviamente, porque anos são definidos como números e, -portanto, o comportamento do campo foi definido bem antes do campo ser -adicionado na tabela. Ainda, imagine que, por algum acidente do destino, eu -precise guardar o ano como uma string[^1]; se o tipo foi alterado, o -_comportamento_ vai ser alterado também? Provavelmente não. +A resposta, no entanto, está em dar um passo atrás e fazer a seguinte +pergunta: _Por que_ o ano é um inteiro? Obviamente, porque anos são definidos +como números[^1] e, portanto, o comportamento do campo foi definido bem antes do +campo ser adicionado na tabela. Ainda, imagine que, por algum acidente do +destino, eu precise guardar o ano como uma string[^2]; se o tipo foi alterado, +o _comportamento_ vai ser alterado também? Provavelmente não. Quando eu ignorei que ano deve ser um número porque "o framework cuida disso pra mim", eu ignorei o comportamento esperado por culpa da implementação. -De novo, "teste comportamentos, não implementação". +E "teste comportamentos, não implementação". Embora não factual, uma anedota: Num projeto, tínhamos um "gerenciador de alarmes" onde, a partir de um evento, poderia ser gerado simplesmente um log, @@ -125,7 +124,7 @@ agora era possível executar centenas de testes em menos de 1 segundo (um exemplo mostra aproximadamente 600 testes em 1.5 segundos). O que o Bernhardt sugere é escrever testes apenas para os models, sem conexão -com o banco ou views; testes de controllers sem conexão com os models ou +com o banco ou controllers; testes de controllers sem conexão com os models ou views; e testes de views sem controllers. Soa familiar (principalmente em frameworks MVC que separam cada um destes em @@ -152,16 +151,16 @@ deixam a impressão que, a longo prazo, eles tendem a se parecerem, estruturalmente, muito parecidos, enquanto que (assim chamados) testes de integração tendem a dar mais retorno a longo prazo para a qualidade do projeto: Testes que definem uma entrada e um resultado esperado tendem a -garantir que, a longo prazo, o funcionando do sistema continuará sendo igual. +garantir que, a longo prazo, o funcionamento do sistema continuará sendo igual. ## Explosão de Testes Lentos A primeira consideração que surge numa declaração como a de cima é que "testes de integração são lentos e isso vai tornar os testes lentos e reduzir a -produtividade dos desenvolvedores. +produtividade dos desenvolvedores." Sim, testes de integração são lentos, principalmente porque há um bom trabalho -em criar o estado esperado, todas as entradas conforme esperado pelo sistema +em criar o estado inicial esperado, todas as entradas conforme esperado pelo sistema de I/O (de novo, interface gráfica, linha de comando, web), percorrer todo o percurso do processamento e verificar a saída. E sim, esperar esse tempo de execução pode acabar distraindo o desenvolvedor. @@ -219,7 +218,7 @@ uma questão sobre qual dos dois testes deveria ser eliminado. Mas nós tínhamo pelas duas validações. Um exemplo mais prático. Imagine uma classe que guarde dados de clientes de -um serviço web de compras[^2]: +um serviço web de compras[^3]: ```python class Client: @@ -232,7 +231,7 @@ está criando usuários sem parar, sem fazer compras, só pra sujar o banco; devemos bloquear todos os cadastros que tenham como nome do usuário apenas um nome. -Aí, pensando em SOLID[^3], o desenvolvedor altera o código para o seguinte: +Aí, pensando em SOLID[^4], o desenvolvedor altera o código para o seguinte: ```python def _multiple_names(name): @@ -251,7 +250,7 @@ class Client: Agora o que acontece é que quando um cliente é criado, são passadas as validações sobre o nome, e uma dessas é que o nome deve ter múltiplos -nomes[^4]. +nomes[^5]. Nova funcionalidade, precisamos de novos testes, certo? @@ -316,7 +315,7 @@ class Client: self.name = name ``` -Pronto, não tem mais validação[^5] e agora a Xuxa pode comprar. Mas ao rodar +Pronto, não tem mais validação[^6] e agora a Xuxa pode comprar. Mas ao rodar os testes: @@ -424,18 +423,20 @@ estão sendo criados. --- -[^1]: O porque vai ser uma string pode ser variado: por causa de um plugin de +[^1]: A não ser que você use anos com números romanos. + +[^2]: O porque vai ser uma string pode ser variado: por causa de um plugin de segurança, porque é feito um armazenamento num banco que não trabalha bem com inteiros, por causa de uma integração com sistema legado... -[^2]: Uma classe de entidades de clientes deveria ser bem mais completa que +[^3]: Uma classe de entidades de clientes deveria ser bem mais completa que essa, mas vamos deixar ela simples assim para fins de exemplo. -[^3]: E não, isso também não é SOLID de verdade, mas vamos deixar assim de +[^4]: E não, isso também não é SOLID de verdade, mas vamos deixar assim de novo para fins de exemplo. -[^4]: E alguém vai me passar o link do "Falácias que Desenvolvedores Acreditam +[^5]: E alguém vai me passar o link do "Falácias que Desenvolvedores Acreditam Sobre Nomes de Usuários" por causa disso. -[^5]: E sim, deveria alterar só o `_validate_name`, mas assim fica mais claro +[^6]: E sim, deveria alterar só o `_validate_name`, mas assim fica mais claro onde está o problema.