Testes de Integração na prática: testando classes que manipulam arquivos com jUnit
Escreva testes automatizados para classes que fazem leitura e escrita de arquivos em disco usando jUnit Rules e TemporaryFolder
É muito comum uma aplicação lidar com leitura ou escrita de arquivos em disco, seja para gravar uma foto de perfil do usuário, exportação de arquivos ou mesmo listar arquivos de um diretório. Esse tipo de tarefa é tão rotineira num sistema corporativo que, por senso comum, um desenvolvedor mais experiente costuma criar uma classe de utilidades responsável por esconder toda a complexidade para manipular arquivos, dessa forma outros membros da equipe podem reutilizá-la sempre que necessário.
E, coincidentemente, este tipo de classe recebe o nome de FileUtils
. Sua implementação não seria muito diferente desta:
public class FileUtils {
/**
* Lista todos os arquivos de um diretorio
*/
public static List<File> lista(File diretorio) {
File[] arquivos = diretorio.listFiles();
return Arrays.asList(arquivos);
}
/**
* Copia arquivo origem para arquivo destino
*/
public static void copia(File origem, File destino) {
destino.getParentFile().mkdirs(); // cria diretorio pai
InputStream input = null;
OutputStream output = null;
try {
input = new FileInputStream(origem);
output = new FileOutputStream(destino);
byte[] buffer = new byte[1024];
int length;
while ((length = input.read(buffer)) > 0) {
output.write(buffer, 0, length);
}
} catch (IOException e) {
throw new IllegalStateException(e);
} finally {
try {input.close();} catch (Exception e) {}
try {output.close();} catch (Exception e) {}
}
}
// outros métodos utilitários
}
Embora ela seja bastante comum, raramente seus testes são levados a sério. No melhor caso ela é testada "por tabela" quando testamos outra funcionalidade do sistema que depende dela. Existem vários motivos para os desenvolvedores não escreverem testes para ela, mas as principais reclamações tem a ver com:
- complexidade de manipular arquivos em disco;
- fragilidade dos testes - ora passa, ora não passa;
- dificuldade de preparar diferentes cenários para cada teste;
- validação do conteúdo dos arquivos;
- lidar com diferentes sistemas operacionais;
Na minha opinião, o maior desafio está na hora de criar cenários, pois estamos lidando com um recurso externo compartilhado, isto é, o disco. Alterações em um diretório são vistos por todos os testes, o que pode interferir na execução de outro teste; além disso, temos que garantir a portabilidade dos testes entre sistemas operacionais.
A verdade é que testar uma classe que se integra com algum recurso/sistema externo tende a ser mais difícil do que uma classe isolada das demais, seja este recurso um disco, um banco de dados ou um servidor HTTP. Nesse momento não estamos mais escrevendo um teste de unidade, mas sim um teste de integração.
Dificuldades dos testes de integração
Para entender o que estou falando, vamos escrever alguns testes de integração com jUnit para a classe FileUtils
acima. Vamos começar pela método lista()
. Basicamente criaremos 2 arquivos num diretório qualquer e listaremos seus arquivos:
public class FileUtilsTest {
@Test
public void deveListarArquivosDoDiretorio() throws IOException {
// cenário
File diretorio = new File("/home/rponte/test-data");
new File(diretorio, "arquivo-1.png").createNewFile();
new File(diretorio, "arquivo-2.png").createNewFile();
// ação
List<File> arquivos = FileUtils.lista(diretorio);
// validação
assertEquals("arquivos encontrados", 2, arquivos.size());
}
}
Se rodarmos este teste teremos como resultado:
Não foi difícil, certo? Lembra bastante um teste de unidade, só que acessando o disco. Vamos continuar então...
Agora, vamos escrever um teste para o método copia()
. Dessa vez, vamos criar um arquivo de origem qualquer e tentaremos copiá-lo para o mesmo diretório porém com um nome diferente. Portanto, nosso código seria semelhante a este:
@Test
public void deveCopiarArquivo() throws IOException {
// cenário
File diretorio = new File("/home/rponte/test-data");
File origem = new File(diretorio, "rponte.png");
origem.createNewFile();
// ação
File destino = new File(diretorio, "rponte-destino.png");
FileUtils.copia(origem, destino);
// validação
assertTrue("destino criado", destino.exists());
assertEquals("mesmo tamanho", origem.length(), destino.length());
}
Se rodarmos esse teste sozinho ele passa! Porém ao rodarmos ambos os testes nós temos como resultado:
Repare que o teste anterior quebrou! Por que? O segundo teste criou 2 arquivos dentro do mesmo diretório, que por sua vez corrompeu o cenário do primeiro teste. Não se assuste, isso é muito comum quando testamos classes integradas a um sistema externo, pois normalmente este sistema é compartilhado e mantém os dados por muito tempo.
Por natureza, um teste de unidade é isolado e independente, enquanto um de integração não. Num teste de unidade não nos esforçamos para isolar os testes, pois tudo está na memória e os objetos são descartados com frequência.
Num teste de integração o desenvolvedor tem que evitar a interdependência entre os testes. Uma prática popular é fazer com que cada método de teste seja um bom cidadão, isto é, que ele limpe a sua própria sujeira. Para isso, podemos criar os bons e velhos métodos com @Before
e @After
do jUnit, como abaixo:
public class FileUtilsTest {
private File diretorio;
@Before
public void prepara() {
diretorio = new File("/home/rponte/test-data");
diretorio.mkdirs();
}
@After
public void limpaSujeira() {
// deleta arquivos do diretorio
for (File f : diretorio.listFiles()) {
f.delete();
}
diretorio.delete(); // deleta diretorio
}
// métodos de teste
}
O método anotado com @Before
rodará antes de cada teste, enquanto o método com @After
rodará ao fim da execução de cada teste.
Bem melhor, não?
Mas ainda temos um problema um tanto quanto sutil: o teste roda na minha minha máquina, mas falhará na máquina de outro desenvolvedor. Por que? Pois eu estou amarrando o diretório na minha HOME: /home/rponte
.
E se outro desenvolvedor usar Windows?
Precisamos encontrar um diretório padrão que exista em todas as máquinas independente de sistema operacional (SO) ou sistema de arquivos. Se pararmos para refletir um pouco, as duas escolhas mais óbvias são:
- dentro do próprio projeto;
- dentro do diretório temporário do SO;
Para não termos que nos preocupar com controle de versão do projeto, vamos usar a 2a opção. Como o caminho desse diretório muda de SO para SO, vamos pedir ajuda ao Java através da classe System
e da propriedade java.io.tmpdir
:
@Before
public void prepara() {
String tmpdir = System.getProperty("java.io.tmpdir");
diretorio = new File(tmpdir, "test-data");
diretorio.mkdirs();
}
Se eu rodar a bateria de testes agora tudo fica verde:
Ufa! Quanta trabalheira para testar 2 métodos! Não se engane, não pára por aí! Ambos os métodos ainda possuem cenários que não testamos: o que acontece se eu listar um diretório vazio? ou se tentar copiar um arquivo inexistente? se o diretório de destino não existir? se já existir um arquivo de destino com o mesmo nome?
Uma boa cobertura de testes envolve encontrar os diversos cenários que uma funcionalidade pode ter. De qualquer forma, vamos esquecer isto por um momento e vamos agora recapitular o que fizemos para manter os testes isolados e independentes:
- criamos um diretório temporário no SO;
- sujamos esse diretório com arquivos dos testes;
- limpamos a sujeira no fim da execução;
Fica fácil entender porque raramente existem testes automatizados para classes que leem ou escrevem em disco, não é? É muita preocupação com o cenário e menos com a lógica e validação dos testes.
jUnit Rules: TemporaryFolder
Uma coisa é certa, seu projeto terá outras classes que lidam com arquivos, por exemplo, classes para importação e exportação, serialização e deserialização de XML, upload e download, processamento de imagens, relatórios em diversos formatos etc. E acabaremos tendo que repetir boa parte do código responsável por preparar e limpar a sujeira dos testes:
@Before
public void prepara() {
// prepara diretorio e arquivos
}
@After
public void limpaSujeira() {
// limpa bagunça feita pelos testes
}
E sabemos que duplicação de código não é legal, certo?
Para evitar repetição de código e facilitar a vida do desenvolvedor, as versões mais recentes do jUnit 4 trazem o recurso Rules. Com ele podemos executar alguma lógica antes e/ou depois de cada teste, semelhante ao @Before
e @After
, porém muito mais poderoso.
Como manipulação de arquivos é uma tarefa rotineira, o jUnit aproveitou e criou a rule TemporaryFolder. Ela faz exatamente o que fizemos acima: cria arquivos em um diretório temporário e limpa a bagunça no final.
Para usar a TemporaryFolder, nossa classe de teste só precisa declarar um atributo público e anota-lo com @Rule
. A classe ficaria como a seguir:
public class FileUtilsTest {
@Rule
public TemporaryFolder temp = new TemporaryFolder();
}
O segundo passo é utilizar o atributo temp
dentro dos testes para criar arquivos e diretórios temporários:
@Test
public void deveListarArquivosDoDiretorio() throws IOException {
// cenário
temp.newFile("arquivo-1.png");
temp.newFile("arquivo-2.png");
// ação
File diretorio = temp.getRoot(); // pega diretório temporário
List<File> arquivos = FileUtils.lista(diretorio);
// validação
assertEquals("total", 2, arquivos.size());
}
@Test
public void deveCopiarArquivo() throws IOException {
// cenário
File origem = temp.newFile("rponte.png");
File destino = new File(temp.getRoot(), "rponte-destino.png");
// ação
FileUtils.copia(origem, destino);
// validação
assertTrue("destino criado", destino.exists());
assertEquals("mesmo tamanho", origem.length(), destino.length());
}
A principal vantagem da TemporaryFolder é que não nos preocupamos mais onde os arquivos serão criados e se serão deletados. Passamos a dedicar nosso foco no que realmente é importante: na lógica do teste e suas validações.
Após a refatoração do teste, se você rodar a bateria você deve obter:
Você já deve saber, mas outra grande vantagem de cobrir uma classe com testes é que podemos refatorá-la sem medo, por exemplo, o código do método FileUtils.copia()
possui uma implementação um tanto quanto rudimentar, quando na verdade poderíamos usar a nova API Files do Java 7 e substituir todo aquele código por 1 única linha:
Files.copy(origem.toPath(), destino.toPath());
Após a refatoração bastaria rodar a bateria de testes novamente e, se os testes passarem, pode colocar em produção com segurança.
Conheça outras Rules do jUnit
O TemporaryFolder não é a única Rule existente. O jUnit possui outras Rules bastante úteis para vários tipos de cenários e testes rotineiros, entre as mais conhecidas temos a ExpectedException para verificar exceções com mais detalhes e a Timeout para aqueles testes que demoraram mais do que o esperado.
Rules são ótimas para testes de integração, pois abrem um leque de opções na hora de gerenciar um sistema externo, como iniciar e derrubar um servidor embarcado; abrir e fechar conexões com o banco; iniciar e fazer rollback de transações; gerar métricas dos testes; levantar containers como Spring ou CDI e muito mais. Ter uma rule bem implementada favorece reutilização de código e simplifica a escrita de testes para toda a equipe.
Enfim, escrever testes de integração requer um pouco mais de cuidado, mas não é um bicho de sete cabeças, principalmente quando respeitamos o princípio de manter os testes isolados e utilizamos as Rules do jUnit. No nosso curso TDD e Testes Automatizados você estuda em detalhes testes de unidade e integração, e diversas práticas para manter a qualidade e uma manutenção sustentável do seu código de testes.
E aí, como você testa classes que lidam com arquivos no seu projeto?
You might also be interested in these articles...
Desenvolvedor e instrutor na TriadWorks