Agile

19 ago, 2014

TDD está morto? Uma história simples sobre um paradoxo

Publicidade

Tenho seguido recentemente com um certo interesse o debate #isTDDDead entre Kent Beck (@kentbeck), David Heinemeier Hansson (@dhh) e Martin Fowler (@ martinfowler). Acho que é particularmente benéfico que as ideias, que são muitas vezes tidas como certas, possam ser contestas de forma construtiva. Dessa forma, você pode descobrir se elas resistem a uma análise ou caem por terra.

A discussão começou com @dhh fazendo as seguintes considerações sobre TDD e técnica de testes, que eu espero ter entendido direito. Em primeiro lugar, a definição estrita de TDD inclui o seguinte:

  1. TTD é usado para conduzir testes unitários
  2. Você não pode ter colaboradores
  3. Você não pode tocar o banco de dados
  4. Você não pode tocar o sistema de arquivos
  5. Testes unitários rápidos e completos em um piscar de olhos.

Ele passou a dizer que você, portanto, conduz a arquitetura do seu sistema a partir do uso de mocks e, dessa forma, ela sofre danos a partir do drive para isolar e fazer os mocks de tudo, enquanto a aplicação obrigatória do ciclo “verde, vermelho, refatorar” é muito precisa. Ele também afirmou que um monte de gente erra ao achar que não pode ter confiança no seu código e você não consegue entregar funcionalidade incremental com testes, a menos que passe por essa estrada mandatada e bem pavimentada do TDD.

@Kent_Beck disse que TDD não inclui necessariamente sarcasmo pesado, e a discussão continuou …

Eu parafraseei um pouco aqui; no entanto, foi a diferença na interpretação e na experiência de utilização de TDD que me fez pensar.

Era realmente um problema com TDD ou foi com a experiência do @dhh da interpretação de outro desenvolvedor de TDD? Não quero colocar palavras na boca do @dhh, mas parece que o problema é a aplicação dogmática da técnica de TDD, mesmo quando não é aplicável. Saí com a impressão de que, em certos locais de desenvolvimento, o TDD tinha degenerado em pouco mais do que Cargo Cult Programming.

O termo Cargo Cult Programming parece derivar de um artigo escrito por alguém que considero realmente inspirador, o falecido professor Richard Feynman. Ele apresentou um documento intitulado Cargo Cult Science – Some remarks on science, pseudoscience and learning how not to fool yourself como parte do discurso de formatura de 1974 da Caltech. Isso, mais tarde, tornou-se parte de sua autobiografia:  Surely you must be joking Mr Feynman, um livro que eu imploro para você ler.

Nele, Feynman destaca experiências de várias pseudociências, como ciência da educação, psicologia, parapsicologia e física, em que a abordagem científica de manter uma mente aberta, questionando tudo e procurando falhas, foi substituída pela crença, ritualismo e fé: uma vontade de controlar outros povos resulta em um controle experimental.

Retirado do artigo de 1974, Feynman resume Cargo Cult Science como:

“Em algum lugar, existem pessoas que são “cargo cult”. Durante a guerra, eles viram os aviões pousarem com vários materiais bons, e querem que agora aconteça a mesma coisa que ocorreu naquela época. Estão eles arrumaram uma forma de imitar coisas como pistas, colocar fogo ao longo dos lados das pistas, fazer uma cabana de madeira para um homem se sentar, com dois pedaços de madeira em sua cabeça, como fones de ouvido e barras de bambu que ficam para fora como antenas – ele é o controlador – e esperar pelos aviões pousarem. Eles estão fazendo tudo certo. A forma é perfeita. É exatamente do jeito que era antes. Mas isso não funciona. Nenhum avião pousa. Então, eu chamo essas coisas de Cargo Cult Science, porque eles seguem todos os preceitos aparentes e as formas de investigação científica, mas ainda sim está faltando algo essencial, porque os aviões não pousam”.

Você pode aplicar essa ideia de programação onde encontrará equipes e indivíduos que realizam procedimentos ritualizados e usando técnicas sem entender realmente a teoria por trás deles, na esperança de que que trabalharão e porque eles são a “coisa certa a fazer”.

Na segunda palestra da série, @dhh veio com um exemplo do que ele chamou de “test induced design damage“, e fiquei animado, porque é algo que eu já vi várias vezes. A única ressalva que eu tinha sobre o código gist era que, para mim, ele não parecia resultado de TDD, esse argumento parece um pouco limitado; eu diria que foi mais um resultado da Cargo Cult Programming, e isso é porque nos casos em que eu me deparei com esse exemplo, o TDD não foi utilizado.

Se você já viu o Gist, deve saber do que estou falando; no entanto, esse código é em Ruby, que é algo em que tenho pouca experiência. A fim de explorar isso com mais detalhes, eu pensei que gostaria de criar uma versão Spring MVC e seguir a partir de lá.

O cenário aqui é aquele em que temos uma história muito simples: tudo o que o código faz é ler um objeto do banco de dados e colocá-lo no modelo de exibição. Não existe nenhum processamento adicional, nenhuma lógica de negócios e nenhum cálculo para executar. A história agile seria mais ou menos isso:

Título: Ver detalhes do usuário
Como um usuário administrador
Quero clicar em um link
Para que eu possa verificar os detalhes de um usuário

Nesse exemplo de arquitetura de camadas “Proper”, eu tenho um modelo de objeto User, uma controller e uma camada de serviço e DAO, juntamente com suas interfaces e testes.

E há o paradoxo: você começou a escrever o melhor código possível para implementar a história, usando o padrão de camada ‘N’ MVC, bem conhecido e provavelmente o mais popular, e termina com algo que é um exagero total para um cenário tão simples. Algo, como @jhh diria, está danificado.

Este é o código de exemplo que demonstra a forma convencional, “certa” de implementar a história; prepare-se para um monte de rolagem…

public class User {

  public static User NULL_USER = new User(-1, "Not Available", "", new Date());

  private final long id;

  private final String name;

  private final String email;

  private final Date createDate;

  public User(long id, String name, String email, Date createDate) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.createDate = createDate;
  }

  public long getId() {
    return id;
  }

  public String getName() {
    return name;
  }

  public String getEmail() {
    return email;
  }

  public Date getCreateDate() {
    return createDate;
  }
}


@Controller
public class UserController {

  @Autowired
  private UserService userService;

  @RequestMapping("/find1")
  public String findUser(@RequestParam("user") String name, Model model) {

    User user = userService.findUser(name);
    model.addAttribute("user", user);
    return "user";
  }
}


public interface UserService {

  public abstract User findUser(String name);

}



@Service
public class UserServiceImpl implements UserService {

  @Autowired
  private UserDao userDao;

  /**
   * @see com.captaindebug.cargocult.ntier.UserService#findUser(java.lang.String)
   */
  @Override
  public User findUser(String name) {
    return userDao.findUser(name);
  }
}



public interface UserDao {

  public abstract User findUser(String name);

}



@Repository
public class UserDaoImpl implements UserDao {

  private static final String FIND_USER_BY_NAME = "SELECT id, name,email,createdDate FROM Users WHERE name=?";

  @Autowired
  private JdbcTemplate jdbcTemplate;

  /**
   * @see com.captaindebug.cargocult.ntier.UserDao#findUser(java.lang.String)
   */
  @Override
  public User findUser(String name) {

    User user;
    try {
      FindUserMapper rowMapper = new FindUserMapper();
      user = jdbcTemplate.queryForObject(FIND_USER_BY_NAME, rowMapper, name);
    } catch (EmptyResultDataAccessException e) {
      user = User.NULL_USER;
    }
    return user;
  }
}

Se você der uma olhada nesse código, verá que paradoxalmente ele parece bom; na verdade, parece um livro de um exemplo clássico de como escrever um aplicativo ‘N’ camadas MVC. O controller passa a responsabilidade ordenando as regras de negócios para a camada de serviço, e ela recupera os dados do DB usando um objeto de acesso a dados, que por sua vez usa uma classe helper rowMapper<> para recuperar um objeto User. Quando o controller tem um objeto User, ele o injeta no modelo pronto para exibição. Esse padrão é claro e extensível; estamos isolando o banco de dados do serviço e este do controller usando interfaces. E estamos, ainda, testando tudo usando tanto JUnit com Mockito, e testes de integração. Essa deve ser a última palavra no livro de codificação do MVC, ou é? Vamos analisar o código.

Em primeiro lugar, existe o uso desnecessário de interfaces. Alguns argumentam que é fácil mudar as implementações de banco de dados, mas quem já faz isso? Além disso, ferramentas de mocks modernas podem criar seus proxies usando definições de classe para que, a menos que seu projeto necessite especificamente de múltiplas implementações da mesma interface, o uso de interfaces seja inútil.

Em seguida, há o UserServiceImpl, que é um exemplo clássico do lazy class anti-pattern, porque ela não faz nada, exceto inutilmente delegar o objeto de acesso a dados. Da mesma forma, o controller também é muito ocioso, uma vez que delega os UserServiceImpl ociosos antes de adicionar a classe User resultante para o modelo: na verdade, todas essas classes são exemplos do lazy class anti-pattern.

Tendo escrito algumas classes lazy, eles já estão testadas inútil e incansavelmente, até mesmo a classe não-evento UserServiceImpl. Só vale a pena escrever testes para as classes que realmente executam alguma lógica.

public class UserControllerTest {

  private static final String NAME = "Woody Allen";

  private UserController instance;

  @Mock
  private Model model;

  @Mock
  private UserService userService;

  @Before
  public void setUp() throws Exception {

    MockitoAnnotations.initMocks(this);

    instance = new UserController();
    ReflectionTestUtils.setField(instance, "userService", userService);
  }

  @Test
  public void testFindUser_valid_user() {

    User expected = new User(0L, NAME, "aaa@bbb.com", new Date());
    when(userService.findUser(NAME)).thenReturn(expected);

    String result = instance.findUser(NAME, model);
    assertEquals("user", result);

    verify(model).addAttribute("user", expected);
  }

  @Test
  public void testFindUser_null_user() {

    when(userService.findUser(null)).thenReturn(User.NULL_USER);

    String result = instance.findUser(null, model);
    assertEquals("user", result);

    verify(model).addAttribute("user", User.NULL_USER);
  }

  @Test
  public void testFindUser_empty_user() {

    when(userService.findUser("")).thenReturn(User.NULL_USER);

    String result = instance.findUser("", model);
    assertEquals("user", result);

    verify(model).addAttribute("user", User.NULL_USER);
  }

}



public class UserServiceTest {

  private static final String NAME = "Annie Hall";

  private UserService instance;

  @Mock
  private UserDao userDao;

  @Before
  public void setUp() throws Exception {

    MockitoAnnotations.initMocks(this);

    instance = new UserServiceImpl();

    ReflectionTestUtils.setField(instance, "userDao", userDao);
  }

  @Test
  public void testFindUser_valid_user() {

    User expected = new User(0L, NAME, "aaa@bbb.com", new Date());
    when(userDao.findUser(NAME)).thenReturn(expected);

    User result = instance.findUser(NAME);
    assertEquals(expected, result);
  }

  @Test
  public void testFindUser_null_user() {

    when(userDao.findUser(null)).thenReturn(User.NULL_USER);

    User result = instance.findUser(null);
    assertEquals(User.NULL_USER, result);
  }

  @Test
  public void testFindUser_empty_user() {

    when(userDao.findUser("")).thenReturn(User.NULL_USER);

    User result = instance.findUser("");
    assertEquals(User.NULL_USER, result);
  }
}



public class UserDaoTest {

  private static final String NAME = "Woody Allen";

  private UserDao instance;

  @Mock
  private JdbcTemplate jdbcTemplate;

  @Before
  public void setUp() throws Exception {

    MockitoAnnotations.initMocks(this);

    instance = new UserDaoImpl();
    ReflectionTestUtils.setField(instance, "jdbcTemplate", jdbcTemplate);
  }

  @SuppressWarnings({ "unchecked", "rawtypes" })
  @Test
  public void testFindUser_valid_user() {

    User expected = new User(0L, NAME, "aaa@bbb.com", new Date());
    when(jdbcTemplate.queryForObject(anyString(), (RowMapper) anyObject(), eq(NAME))).thenReturn(expected);

    User result = instance.findUser(NAME);
    assertEquals(expected, result);
  }

  @SuppressWarnings({ "unchecked", "rawtypes" })
  @Test
  public void testFindUser_null_user() {

    when(jdbcTemplate.queryForObject(anyString(), (RowMapper) anyObject(), isNull())).thenReturn(User.NULL_USER);

    User result = instance.findUser(null);
    assertEquals(User.NULL_USER, result);
  }

  @SuppressWarnings({ "unchecked", "rawtypes" })
  @Test
  public void testFindUser_empty_user() {

    when(jdbcTemplate.queryForObject(anyString(), (RowMapper) anyObject(), eq(""))).thenReturn(User.NULL_USER);

    User result = instance.findUser("");
    assertEquals(User.NULL_USER, result);
  }

}



@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration({ "file:src/main/webapp/WEB-INF/spring/appServlet/servlet-context.xml",
    "file:src/test/resources/test-datasource.xml" })
public class UserControllerIntTest {

  @Autowired
  private WebApplicationContext wac;

  private MockMvc mockMvc;

  /**
   * @throws java.lang.Exception
   */
  @Before
  public void setUp() throws Exception {

    mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
  }

  @Test
  public void testFindUser_happy_flow() throws Exception {

    ResultActions resultActions = mockMvc.perform(get("/find1").accept(MediaType.ALL).param("user", "Tom"));
    resultActions.andExpect(status().isOk());
    resultActions.andExpect(view().name("user"));
    resultActions.andExpect(model().attributeExists("user"));
    resultActions.andDo(print());

    MvcResult result = resultActions.andReturn();
    ModelAndView modelAndView = result.getModelAndView();
    Map<String, Object> model = modelAndView.getModel();

    User user = (User) model.get("user");
    assertEquals("Tom", user.getName());
    assertEquals("tom@gmail.com", user.getEmail());
  }

}

 

Ao escrever esse código de exemplo, eu adicionei nessa mistura tudo que veio à minha cabeça. Você pode pensar que esse exemplo é exagerado na sua construção, especialmente com a inclusão na interface redundante, mas eu já vi códigos como esse.

Os benefícios desse padrão são que ele segue um design distinto entendido pela maioria dos desenvolvedores; é limpo e extensível. O lado negativo é que existem muitas classes. Quanto mais classes, mais tempo se leva para escrever, e você sempre precisa manter ou melhorar esse código, eles são mais difíceis de a gente se familiarizar.

Então, qual é a solução? Isso é difícil de responder. No debate #IsTTDDead, @dhh dá como solução colocar todo aquele código em uma classe, misturando o acesso a dados com a população do modelo. Se implementar essa solução para a nossa história de usuário, você ainda obtêm uma classe User, mas o número de classes que você precisa reduz drasticamente.

@Controller
public class UserAccessor {

  private static final String FIND_USER_BY_NAME = "SELECT id, name,email,createdDate FROM Users WHERE name=?";

  @Autowired
  private JdbcTemplate jdbcTemplate;

  @RequestMapping("/find2")
  public String findUser2(@RequestParam("user") String name, Model model) {

    User user;
    try {
      FindUserMapper rowMapper = new FindUserMapper();
      user = jdbcTemplate.queryForObject(FIND_USER_BY_NAME, rowMapper, name);
    } catch (EmptyResultDataAccessException e) {
      user = User.NULL_USER;
    }
    model.addAttribute("user", user);
    return "user";
  }

  private class FindUserMapper implements RowMapper<User>, Serializable {

    private static final long serialVersionUID = 1L;

    @Override
    public User mapRow(ResultSet rs, int rowNum) throws SQLException {

      User user = new User(rs.getLong("id"), //
          rs.getString("name"), //
          rs.getString("email"), //
          rs.getDate("createdDate"));
      return user;
    }
  }
}



@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration({ "file:src/main/webapp/WEB-INF/spring/appServlet/servlet-context.xml",
    "file:src/test/resources/test-datasource.xml" })
public class UserAccessorIntTest {

  @Autowired
  private WebApplicationContext wac;

  private MockMvc mockMvc;

  /**
   * @throws java.lang.Exception
   */
  @Before
  public void setUp() throws Exception {

    mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
  }

  @Test
  public void testFindUser_happy_flow() throws Exception {

    ResultActions resultActions = mockMvc.perform(get("/find2").accept(MediaType.ALL).param("user", "Tom"));
    resultActions.andExpect(status().isOk());
    resultActions.andExpect(view().name("user"));
    resultActions.andExpect(model().attributeExists("user"));
    resultActions.andDo(print());

    MvcResult result = resultActions.andReturn();
    ModelAndView modelAndView = result.getModelAndView();
    Map<String, Object> model = modelAndView.getModel();

    User user = (User) model.get("user");
    assertEquals("Tom", user.getName());
    assertEquals("tom@gmail.com", user.getEmail());
  }

  @Test
  public void testFindUser_empty_user() throws Exception {

    ResultActions resultActions = mockMvc.perform(get("/find2").accept(MediaType.ALL).param("user", ""));
    resultActions.andExpect(status().isOk());
    resultActions.andExpect(view().name("user"));
    resultActions.andExpect(model().attributeExists("user"));
    resultActions.andExpect(model().attribute("user", User.NULL_USER));
    resultActions.andDo(print());
  }
}

A solução acima reduz o número de classes de primeiro nível para duas: uma de implementação e uma de teste. Todos os cenários de teste são atendidos em um número muito reduzido de testes de integração “end to end”. Estes vão acessar o banco de dados, mas isso é tão ruim assim nesse caso? Se cada ida ao DB leva em torno de 20ms ou menos, então eles vão ainda completar dentro de uma fração de segundo, que deve ser rápido o suficiente.

Em termos de melhorar ou manter esse código, uma pequena classe única é mais fácil de aprender do que várias classes ainda menores. Se você teve que adicionar um monte de regras de negócios ou outras complexidades alternadas, então esse código não será difícil para a camada padrão ‘N’; no entanto, o problema é que se/ quando uma mudança for necessária, ela pode ser dada a um desenvolvedor inexperiente, que não estará confiante o suficiente para realizar a refatoração necessária. A conclusão é, e você deve ter visto isso várias vezes, que a nova mudança poderia ser levada para o topo dessa solução de uma classe, levando a uma confusão danada de código.

Na implementação de uma solução como esta, você pode não ser muito popular, porque o código é pouco convencional. Essa é uma das razões pelas quais considero esta solução de classe única algo que muita gente veria como controversa. É essa ideia de um padrão “caminho certo” e “caminho errado” de escrever código, rigorosamente aplicada em cada caso, que levou esse design bom a se tornar um problema.

Eu acho que é tudo uma questão de opinião; escolher o projeto certo para a situação certa. Se eu estava implementando uma história complexa, então eu não hesitaria em dividir as várias responsabilidades, mas, no caso simples, não vale a pena.

O código para este artigo está disponível no GitHub.

***

Artigo traduzido pela Redação iMasters com autorização do autor. Publicado originalmente em http://www.captaindebug.com/2014/06/the-simple-story-paradox.html#.U7sS4vldXfQ