Para que serve:

Fornecer boas práticas para criação de casos de teste de integração, visando testes mais simples e eficazes. Além disso, mostrar as dificuldades encontradas durante os testes e como resolvê-las. O artigo também apresenta uma introdução ao TestNG e orienta o leitor a utilizar o framework de forma eficiente, se valendo das principais facilidades oferecidas pela ferramenta.

Em que situação o tema é útil:

Teste de software é um tema vasto e complexo e o teste de integração é uma das fases de teste menos conhecidas. O uso de técnicas eficientes e ferramentas especializadas tornam essa atividade produtiva e eficaz. Quando usado corretamente, o TestNG é uma excelente ferramenta para apoio aos testes de integração, pois possui recursos avançados que facilitam a vida dos desenvolvedores e testadores.

Guia do artigo:

Testes avançados com o TestNG

A fase de teste de integração possui uma importância crucial no desenvolvimento de software. Apesar disso, essa fase não recebe a atenção merecida e a maioria das ferramentas existentes ainda tem seu foco dirigido ao teste unitário.

Nesse artigo o TestNG é utilizado para construção de testes integrados através de um exemplo real e completo. Os casos de teste são derivados a partir de um Caso de Uso e todas as decisões tomadas são explicadas detalhadamente. Além disso, a aplicação exemplo é refatorada para aumentar sua testabilidade e facilitar sua evolução, uma prática valiosa e recomendada no teste de aplicativos complexos.

Usando recursos do framework e da nova especificação Java EE, o teste é projetado, construído e executado independente do container Web e do provedor JMS.

O teste de software é uma atividade que tem ganhado bastante atenção da comunidade Java. A cada dia novas ferramentas são criadas e mais desenvolvedores reconhecem a importância da atividade, se tornando adeptos de suas práticas.

Apesar disso, nota-se que o teste unitário é o que recebe a maior ênfase, em detrimento das outras fases de teste. Isso pode ser visto através das inúmeras ferramentas e da vasta bibliografia disponíveis. Neste artigo abordaremos o teste de integração e utilizaremos uma ferramenta que cada vez ganha mais simpatizantes: o TestNG.

Teste de integração

Conceitos básicos

Como o próprio nome sugere, a ideia por trás do teste de integração é verificar o funcionamento integrado das diversas “unidades” que compõem um software. Em geral considera-se como “unidade” um método ou uma classe (que são avaliados através de testes unitários); os testes integrados são aqueles que exercitam essas unidades conjuntamente, considerando as suas comunicações e dependências.

De acordo com a estratégia adotada pela equipe de qualidade, o teste de integração pode ser realizado após os testes unitários (abordagem bottom-up), ou anteriormente a essa fase (abordagem top-down). A primeira abordagem é a mais comum, pois os testes unitários em geral são mais simples de criar e executar, além dessa prática já estar disseminada entre os desenvolvedores de software.

A abordagem top-down, no entanto, pode oferecer uma medida de qualidade próxima do funcionamento real do produto, mesmo que os módulos (unidades) ainda não tenham sido avaliados individualmente. Um exemplo típico é aquele em que é necessário fornecer ao cliente uma versão beta do software, que ainda não passou por todos os testes unitários, mas já contém a maioria das funcionalidades previstas no produto final. Nesse cenário o funcionamento conjunto das partes precisa estar garantido e o uso da abordagem top-down é justificado.

Qualidade dos casos de teste

Independente da fase em que ocorre, a atividade de teste tem como objetivo primordial encontrar defeitos no software. Quando um conjunto de casos de teste não encontra erros no programa sob teste sempre paira uma dúvida no ar: o software realmente possui qualidade, ou o teste não foi bem feito? Na grande maioria dos casos, poucos defeitos encontrados durante a execução dos testes indicam casos de teste mal elaborados.

Fazendo uma estimativa hipotética do problema, vamos considerar que um bom programador introduz ao menos um defeito no software a cada 100 linhas de código escritas (uma taxa de 1%). Com essa média teríamos aproximadamente 1.000 defeitos em um software com as dimensões do framework Spring (100.000 linhas de código - Análise do Spring 2.5.4 utilizando oEclipse Metric plugin)!

À medida que os softwares crescem em tamanho e complexidade, mais desafiador se torna o trabalho da equipe de qualidade. Como garantir que os defeitos existentes serão encontrados e que os resultados dos testes realmente refletirão a qualidade do software?

As técnicas e critérios de teste são utilizados para auxiliar o testador na criação dos casos de teste e, principalmente, na medição do que já foi testado e indicação de quando a atividade de teste pode ser finalizada. Na edição 50 da Java Magazine introduzimos os critérios “Particionamento de Equivalência” e “Análise de valor-limite”; nesse artigo utilizaremos Casos de Uso para extrair os requisitos de teste e especificar os casos de teste.

Utilizando Casos de Uso

Casos de Uso oferecem uma excelente fonte para derivação de casos de teste. Esses artefatos definem cenários do software sob a ótica do usuário, através de um fluxo principal e caminhos alternativos e de exceção.

Casos de Uso determinam o fluxo do processo de negócio e a interação entre o usuário e o sistema, independente dos módulos que estão envolvidos. Assim, casos de teste baseados nessa especificação são eficazes em revelar defeitos decorrentes do funcionamento integrado dos módulos do software, semelhante ao seu funcionamento no “mundo real”.

É interessante notar que assim como os Casos de Uso auxiliam na derivação dos casos de teste, esses ajudam a encontrar inconsistências, omissões ou ambiguidades na especificação do software.Técnicas de inspeção como aPerspective-Based Reading(PBR) são valiosas para produzir especificações mais consistentes e por isso devem ser utilizadas sempre que possível.

Os dois artefatos são complementares, e por isso as equipes responsáveis por cada um precisam estar em sintonia desde as primeiras etapas de desenvolvimento do software.

Um exemplo aplicado

Sem dúvida, a melhor maneira de se aprender sobre testes é através de um exemplo prático. Por isso, utilizaremos um programa real ao longo deste artigo.

Especificação

A empresa Code Company ministra diferentes cursos teóricos e práticos. Cada curso possui uma estrutura própria, mas em geral é composto por diversos módulos e os alunos são avaliados ao final de cada um. O curso “Introdução à linguagem Java”, por exemplo, é formado de quatro módulos no estilo hands-on e os exercícios são aplicados ao término de cada fase. A especificação de cada exercício é enviada para os alunos por e-mail, e esses devem criar o programa Java correspondente e submetê-lo através do site do curso (via upload).

A correção do exercício é feita de forma automatizada pelo corretor da Code Company. Para cada exercício especificado, uma bateria de testes pré-fabricados verifica o funcionamento do código e emite um relatório para o aluno.

A especificação completa do Caso de Uso “Avaliar programa” pode ser vista no quadro “Especificação do Corretor Java”. É importante notar que essa especificação é descrita em alto nível (no nível de negócio) e nenhuma tecnologia ou componente é definido nesse momento.

De forma resumida, o código submetido pelo aluno é enviado para uma fila JMS. Componentes especializados são responsáveis por retirar a mensagem da fila, extrair o arquivo Java, compilá-lo, executar os testes correspondentes e enviar o relatório para o aluno por e-mail.

Especificação do Corretor Java

O principal Caso de Uso do Corretor Java denomina-se “Avaliar programa”. Sua especificação é descrita abaixo:

FB.1 O aluno submete o arquivo a ser testado através do site da universidade. Além do arquivo, os seguintes dados devem ser informados:
  • Nome do arquivo;
  • E-mail do aluno.
FB.2 O sistema grava o arquivo em um diretório com uma identificação única. Esse diretório deve ser mantido por questões de auditoria.
FB.3 O sistema compila o arquivo. Caso ocorra algum erro durante a compilação, o fluxo é desviado paraFE.1.
FB.4 O sistema copia os casos de teste correspondentes ao exercício para o diretório descrito no passoFB.2.
FB.5 O sistema executa os casos de teste. Caso ocorra algum erro durante a execução dos testes, o fluxo é desviado paraFE.1.
FB.6 Caso tenha ocorrido algum erro durante a compilação, o fluxo é desviado paraFA.1.Caso ocorra algum erro durante a execução dos testes, o fluxo é desviado paraFA.2. Caso contrário o sistema envia um e-mail ao usuário com o título “Testes executados com sucesso” e o relatório de testes anexado.
FB.7 O caso de uso é encerrado.
FE.1 O sistema lança a exceção correspondente e segue para o passoFB.6.
FA.1 O sistema envia um e-mail ao usuário com o título “Erro na compilação”. O log de erro deve ser colocado no corpo da mensagem e nenhum arquivo deve ser anexado. O fluxo é desviado paraFB.7.
FA.2 O sistema envia um e-mail ao usuário com o título “Erro na execução dos testes”. O log de erro deve ser colocado na mensagem e o relatório de testes anexado. O fluxo é desviado paraFB.7.

Implementação

O principal componente do Corretor Java é a classe CorretorBean (Listagem 1), um Message-Driven Bean que é responsável por tratar a mensagem recebida, extraindo o arquivo Java e os dados do aluno, e executar os testes correspondentes. Esses procedimentos são feitos a partir do método onMessage().

Listagem 1. Classe CorrecaoBean, responsável por tratar o arquivo enviado a fila JMS
package org.codecompany.correcao;

        ...

        @MessageDriven(activationConfig = {
        @ActivationConfigProperty(propertyName = "destinationType",
        propertyValue = "javax.jms.Topic"),
        @ActivationConfigProperty(propertyName = "destination", propertyValue = "Correcao") },
        mappedName = "jms/Correcao")
        public class CorrecaoBean implements MessageListener {

        public static final String DIR_ARQUIVOS = "C:/CORRECAO_ARQUIVOS";

        public void onMessage(Message mensagem) {
        String destinatario = null;
        String sessaoDir = null;
        File relatorioDir = null;
        Exception excecao = null;

        try {
        TextMessage mensagemTexto = (TextMessage) mensagem;
        destinatario = mensagemTexto.getStringProperty("email");
        classe = mensagemTexto.getStringProperty("classe");
        arquivo = classe.substring(classe.lastIndexOf('.') + 1) + ".java";

        // diretório único
        sessaoDir = DIR_ARQUIVOS + File.separator
        + UUID.randomUUID().toString();

        // diretório contendo relatório dos testes
        relatorioDir = new File(sessaoDir + File.separator + "relatorio");
        relatorioDir.mkdirs();

        // salva o arquivo em disco
        salvarArquivo(mensagemTexto, arquivo, sessaoDir);

        // cria o executor do Ant
        ExecutorAnt executor = new ExecutorAnt(sessaoDir, arquivo);

        // compila o arquivo
        executor.compilarArquivos();

        // diretório que contém os casos de teste para o programa
        String testesDir = TestesLocator.getInstance().getTestesDir(classe);

        // executa os testes correspondentes
        executor.executarTestes(testesDir, relatorioDir);

        } catch (Exception e) {
        excecao = e;
        e.printStackTrace();
        } finally {
        // envia email com relatório dos testes
        EnvioEmail envioEmail = new EnvioEmail(destinatario, sessaoDir,
        relatorioDir.getAbsolutePath(), excecao);
        envioEmail.enviar();
        }
        }

        private File salvarArquivo(TextMessage mensagemTexto, String arquivoJava,
        String path) throws JMSException, IOException {
        ...
        }
        }
        

O arquivo submetido pelo aluno é salvo em disco através do método salvarArquivo(). Após esse procedimento ser executado, a classe ExecutorAnt (Listagem 2) entra em ação. Essa classe utiliza a API do Apache Ant para compilar o arquivo e executar todos os testes relacionados utilizando o TestNG. Os métodos da classe são executados da seguinte forma (Listagem 1):

ExecutorAnt executor = new ExecutorAnt(...);
        executor.compilarArquivos();
        executor.executarTestes(...);
        
Listagem 2. Classe ExecutorAnt, responsável pela compilação e execução dos testes
package org.codecompany.correcao.util;

        ...

        public class ExecutorAnt {

        private String sessaoDir;
        private Project project;

        public ExecutorAnt(String sessaoDir) {
        this.sessaoDir = sessaoDir;
        project = new Project();
        project.addBuildListener(new DefaultLogger());
        project.init();
        }

        public void compilarArquivos() throws CompilacaoException {
        Javac javac = (Javac) project.createTask("javac");
        ...
        try {
        javac.perform();
        } catch (Exception e) {
        throw new CompilacaoException(e);
        }
        }

        public void executarTestes(String testesDir, File relatorioDir) throws CopiaException, ExecucaoTestesException {
        copiarArquivos(testesDir);
        TestNGAntTask testng = new TestNGAntTask();
        ...
        try {
        testng.perform();
        } catch (Exception e) {
        throw new ExecucaoTestesException(e);
        }
        }

        private void copiarArquivos(String testesDir) throws CopiaException {
        Copy copy = (Copy) project.createTask("copy");
        ...
        try {
        copy.perform();
        } catch (Exception e) {
        throw new CopiaException(e);
        }
        }
        }
        

Depois que os testes são realizados, um e-mail com o resultado dos testes é enviado ao aluno. Essa etapa é executada pela classe EnvioEmail (Listagem 3). A chamada a esse componente é apresentada abaixo (Listagem 1):

EnvioEmail envioEmail = new EnvioEmail(...);
        envioEmail.enviar();
        
Listagem 3. Classe EnvioEmail, responsável pelo envio do email contendo o relatório dos testes
package org.codecompany.correcao.util;

        ...

        import org.codecompany.correcao.excecao.ExecucaoTestesException;

        public class EnvioEmail {

        public static final String HOST = "smtp.gmail.com";
        public static final String PORTA = "465";
        public static final String REMETENTE = "corretor@codecompany.com.br";
        public static final String SSL_FACTORY = "javax.net.ssl.SSLSocketFactory";
        public static final String NOME_ZIP = "relatorio.zip";

        private MimeMessage mensagem = null;

        public EnvioEmail(String destinatario, String arquivosDir,
        String relatorioDir, Exception excecao) {
        try {
        mensagem = criarMensagem(destinatario, arquivosDir, relatorioDir,
        excecao);
        } catch (Exception e) {
        e.printStackTrace();
        }
        }

        public boolean enviar() {
        boolean sucesso = true;

        try {
        Transport.send(mensagem);
        } catch (MessagingException e) {
        sucesso = false;
        e.printStackTrace();
        }

        return sucesso;
        }

        private MimeMessage criarMensagem(String destinatario, String arquivosDir,
        String relatorioDir, Exception excecao) throws MessagingException,
        AddressException {
        ...
        Session sessao = Session.getInstance(props,
        new javax.mail.Authenticator() {
        protected PasswordAuthentication getPasswordAuthentication() {
        return new PasswordAuthentication(
        "usuario@gmail.com", "senha");
        }
        });
        sessao.setDebug(true);

        // configuração básicas
        MimeMessage mensagem = new MimeMessage(sessao);
        mensagem.setFrom(new InternetAddress(REMETENTE));
        mensagem.addRecipient(Message.RecipientType.TO, new InternetAddress(
        destinatario));

        if (excecao != null) {
        mensagem.setSubject(excecao.getMessage());
        } else {
        mensagem.setSubject("Testes executados com sucesso");
        }

        Multipart partes = new MimeMultipart();
        anexarTexto(partes, excecao);

        if (excecao == null || excecao instanceof ExecucaoTestesException) {
        anexarArquivo(partes, arquivosDir, relatorioDir);
        }

        // preenche o conteúdo da mensagem
        mensagem.setContent(partes);
        return mensagem;
        }

        private void anexarTexto(Multipart partes, Exception excecao)
        throws MessagingException {

        MimeBodyPart corpoMensagem = new MimeBodyPart();

        // texto da mensagem
        if (excecao != null) {
        corpoMensagem.setText(Util.converterEmTexto(excecao));
        } else {
        corpoMensagem.setText("Testes executados com sucesso");
        }

        partes.addBodyPart(corpoMensagem);
        }

        private void anexarArquivo(Multipart partes, String arquivosDir,
        String relatorioDir) throws MessagingException {
        ...
        }
        }
        

Derivando os casos de teste

Antes de iniciar os testes do aplicativo é essencial definir a fronteira do que será testado. Cada fase de teste deve ter um escopo bem definido, caso contrário a complexidade e o tamanho dos testes poderão tornar essa atividade impossível de ser realizada. Com isso em mente, definiremos as premissas para os nossos testes de integração.

Nosso interesse é verificar o funcionamento integrado dos diversos módulos do programa, contemplando o fluxo de negócio completo: recebimento do arquivo, compilação, execução dos testes e envio do relatório. Não vamos nos preocupar nesse momento com os detalhes internos de cada método ou classe. Isso deverá ser exercitado na fase de teste unitário.

Além disso, os testes de infra-estrutura estão fora do escopo da nossa validação. Verificações desse tipo são essenciais, mas devem ser executadas por uma bateria de testes específica (é um engano comum ao testador inexperiente “misturar” testes de infra e de negócio).

Assim, partiremos do pressuposto que toda infra-estrutura necessária está configurada corretamente e funciona de acordo com o previsto, pois já foi testada ou será em um momento oportuno. Não será verificada a comunicação JMS ou SMTP, pois se entende que as mensagens são tratadas corretamente por ambos os serviços.

Definir claramente o quê será testado torna o teste mais simples, eficiente e objetivo.

Requisitos de teste

Para facilitar a derivação dos requisitos de teste, uma especificação de Caso de Uso pode ser transformada facilmente em um grafo, visto que os fluxos básico, alternativos e de exceção fornecem os possíveis caminhos do programa. A Figura 1 mostra a especificação do nosso Caso de Uso transformada em um grafo.

Representando o Caso de Uso “Avaliar programa” sob a forma de grafo
Figura 1. Representando o Caso de Uso “Avaliar programa” sob a forma de grafo

A partir do grafo gerado é muito simples derivar os casos de teste para o nosso programa: os casos de teste representam caminhos no grafo, em uma estratégia semelhante àquela utilizada no teste estrutural. O objetivo é criar casos de teste que exercitem todas as arestas do grafo, verificando assim o funcionamento do software sob condições normais (testes positivos) e sob condições adversas (testes negativos).

Casos de teste

Seguindo essa premissa, podemos elaborar três casos de teste de integração. O primeiro verifica o funcionamento do software quando um arquivo correto é submetido pelo aluno. O segundo verifica o que ocorre quando um arquivo com erro de compilação é submetido. Por fim, o último caso de teste verifica o comportamento do sistema quando um arquivo com erro de lógica é submetido.

A Tabela 1 descreve a especificação do primeiro caso de teste, aquele que verifica o comportamento do software quando um arquivo correto é enviado (fluxo básico). A Figura 2 mostra o grafo correspondente a esse caso de teste, onde se destacam os fluxos que são exercitados nesse cenário.

Tabela 1. Especificação para o caso de teste “Envio de arquivo com erro de compilação”
Identificador CJ_TI_0001_01
Objetivo Verificar o comportamento do software quando um arquivo correto é submetido.
Fluxos exercitados FB.1, FB.2, FB.3, FB.4, FB.5, FB.6, FB.7.
Precondições Ambiente devidamente configurado.
Procedimentos Submeter o arquivo Java. O arquivo deve ser compilável e com a lógica correta.
Resultados esperados O e-mail deve ser enviado para o aluno com o título “Testes executados com sucesso”. O e-mail deve conter um anexo com o relatório dos testes.

A especificação de um caso de teste deve incluir elementos que facilitem sua identificação e execução. Assim, informações como identificador, objetivo e resultados esperados são essenciais. Esses dados guiam a execução dos testes, auxiliando sua configuração e automação. A norma IEEE 829 (Standard for Software Test Documentation) define diversos documentos e padrões de especificação e é um excelente guia para os testadores.

Agora que os casos de teste foram especificados, partiremos para sua construção.

Envio de um arquivo contendo erro de compilação
Figura 2. Envio de um arquivo contendo erro de compilação

Analisando a implementação

Até agora entendemos os requisitos do software e derivamos os casos de teste que serão automatizados. A próxima etapa é verificar as dificuldades existentes para se testar a implementação atual do Corretor Java.

Testabilidade

Uma das maneiras de analisar o software em teste é através da sua testabilidade. De forma simples, podemos dizer que testabilidade refere-se à facilidade que um programa oferece de ser testado. A depender da forma como o software foi construído pode ser mais simples ou complicado enxergar o estado dos seus objetos e verificar se estão corretos ou não. Por exemplo, métodos privados dificultam os testes e acoplamento com APIs e entidades externas também.

Para aumentar a testabilidade do nosso software faremos pequenas intervenções nas classes CorrecaoBean e EnvioEmail. A ideia é expor o estado do software, permitindo a sua inspeção durante a execução dos testes. Esse refactoring é um dos efeitos colaterais positivos do teste, e contribui para um código mais elegante e de melhor manutenção.

Alterando a classe CorrecaoBean

Analisando a classe CorrecaoBean (Listagem 1) verificamos que o método onMessage() é quem dispara todo procedimento de correção do arquivo enviado pelo aluno. É a partir desse método que o arquivo é salvo em disco, compilado e os testes elaborados pelo professor são executados.

Aqui nos deparamos com o primeiro problema do projeto do nosso software: o método onMessage() faz parte da interface javax.jms.MessageListener, e por isso sua execução exige a existência da infra-estrutura JMS para criação de mensagens (javax.jms.TextMessage) e envio à fila. Isso demanda a instalação e configuração de um servidor de mensagens, uma tarefa nem sempre simples. Para resolver esse problema faremos pequenas (e importantes) alterações no código, sem mudar a lógica de negócio.

Lembramos que o teste de infra-estrutura não é nosso interesse no momento. O foco é a integração entre os componentes de negócio do sistema. Ainda que seja possível simular o JMS através de mocks, minimizar a dependência de APIs e aumentar a testabilidade do software é uma estratégia excelente, resultando em um software mais simples e robusto.

A Listagem 4 apresenta a nova classe CorrecaoBean. As variáveis sessaoDir e relatorioDir foram transformadas em atributos da classe e o método onMessage() foi alterado para cuidar apenas do tratamento da mensagem, delegando a lógica da correção para o método analisarArquivo() e o envio do relatório para o método enviarRelatorio(). A criação dos diretórios também foi apartada em um método específico: criarDiretorios().

Além disso, o método salvarArquivo() foi transformado em público e alterado para trabalhar diretamente com o texto da mensagem (ao invés da mensagem javax.jms.TextMessage). Isso desacopla o método do JMS, permitindo que seja utilizado sem um provedor de mensagens.

Listagem 4. Classe CorretorBean refatorada para facilitar os teste
package org.codecompany.correcao;

        ...

        @MessageDriven(activationConfig = {
        @ActivationConfigProperty(propertyName = "destinationType",
        propertyValue = "javax.jms.Topic"),
        @ActivationConfigProperty(propertyName = "destination", propertyValue = "Correcao") },
        mappedName = "jms/Correcao")
        public class CorrecaoBean implements MessageListener {

        public static final String DIR_ARQUIVOS = "C:/CORRECAO_ARQUIVOS";

        String sessaoDir = null;
        File relatorioDir = null;

        ...

        public void onMessage(Message mensagem) {
        String destinatario = "";
        String classe = "";
        String arquivo = "";

        // cria os diretórios para o arquivo e relatórios
        criarDiretorios();

        // extrai os dados da mensagem
        try {
        TextMessage mensagemTexto = (TextMessage) mensagem;
        destinatario = mensagemTexto.getStringProperty("email");
        classe = mensagemTexto.getStringProperty("classe");
        arquivo = classe.substring(classe.lastIndexOf('.') + 1) + ".java";

        // salva o arquivo em disco
        salvarArquivo(mensagemTexto.getText(), arquivo);

        } catch (Exception e) {
        e.printStackTrace();
        }

        Exception excecao = null;
        try {
        analisarArquivo(arquivo, classe, destinatario);
        } catch (Exception e) {
        excecao = e;
        } finally {
        enviarRelatorio(destinatario, sessaoDir, relatorioDir, excecao);
        }
        }

        private void analisarArquivo(String arquivo, String classe,
        String destinatario) {

        // cria o executor do Ant
        ExecutorAnt executor = new ExecutorAnt(sessaoDir);

        // compila o arquivo
        executor.compilarArquivos();

        // diretório que contém os casos de teste para o programa
        String testesDir = TestesLocator.getInstance().getTestesDir(classe);

        // executa os testes correspondentes
        executor.executarTestes(testesDir, relatorioDir);
        }

        private void enviarRelatorio(String destinatario, String sessaoDir,
        File relatorioDir, Exception excecao) {
        // envia email com relatório dos testes
        EnvioEmail envioEmail = new EnvioEmail(destinatario, sessaoDir,
        relatorioDir.getAbsolutePath(), excecao);

        envioEmail.enviar();
        }

        public String criarDiretorios() {
        // diretório único
        sessaoDir = DIR_ARQUIVOS + File.separator
        + UUID.randomUUID().toString();

        // diretório contendo relatório dos testes
        relatorioDir = new File(sessaoDir + File.separator + "relatorio");
        relatorioDir.mkdirs();

        return sessaoDir;
        }

        public File salvarArquivo(String texto, String arquivoJava) throws IOException {
        new File(sessaoDir).mkdirs();
        File arquivo = new File(sessaoDir + File.separator + arquivoJava);

        Writer output = new BufferedWriter(new FileWriter(arquivo));
        output.write(texto);
        output.close();

        return arquivo;
        }
        }
        

Alterando a classe EnvioEmail

As alterações na classe EnvioEmail (Listagem 5) são simples. Basicamente, a classe foi alterada para permitir acesso ao destinatário, título, texto e anexo da mensagem através de quatro novos métodos: getDestinatario(), getTitulo(), getTextoMensagem() e getArquivoMensagem().

Listagem 5. Classe EnvioEmail refatorada para facilitar os teste
package org.codecompany.correcao.util;

        ...

        public class EnvioEmail {

        public static final String HOST = "smtp.gmail.com";
        public static final String PORTA = "465";
        public static final String REMETENTE = "corretor@codecompany.com.br";
        public static final String SSL_FACTORY = "javax.net.ssl.SSLSocketFactory";
        public static final String NOME_ZIP = "relatorio.zip";

        private MimeMessage mensagem = null;
        private MimeBodyPart anexoMensagem = null;
        private MimeBodyPart corpoMensagem = null;

        public EnvioEmail(String destinatario, String arquivosDir,
        String relatorioDir, Exception excecao) {
        try {
        criarMensagem(destinatario, arquivosDir, relatorioDir, excecao);
        } catch (Exception e) {
        e.printStackTrace();
        }
        }

        public boolean enviar() {
        boolean sucesso = true;

        try {
        Transport.send(mensagem);
        } catch (MessagingException e) {
        sucesso = false;
        e.printStackTrace();
        }

        return sucesso;
        }

        private void criarMensagem(String destinatario, String arquivosDir,
        String relatorioDir, Exception excecao) throws MessagingException,
        AddressException {

        ...
        Session sessao = Session.getInstance(props,
        new javax.mail.Authenticator() {
        protected PasswordAuthentication getPasswordAuthentication() {
        return new PasswordAuthentication(
        "alguem@gmail.com", "senha");
        }
        });
        sessao.setDebug(true);

        // configuração básicas
        mensagem = new MimeMessage(sessao);
        mensagem.setFrom(new InternetAddress(REMETENTE));
        mensagem.addRecipient(Message.RecipientType.TO, new InternetAddress(
        destinatario));

        if (excecao != null) {
        mensagem.setSubject(excecao.getMessage());
        } else {
        mensagem.setSubject("Testes executados com sucesso");
        }

        Multipart partes = new MimeMultipart();
        anexarTexto(partes, excecao);

        if (excecao == null || excecao instanceof ExecucaoTestesException) {
        anexarArquivo(partes, arquivosDir, relatorioDir);
        }

        // preenche o conteúdo da mensagem
        mensagem.setContent(partes);
        }

        private void anexarTexto(Multipart partes, Exception excecao)
        throws MessagingException {

        corpoMensagem = new MimeBodyPart();

        // texto da mensagem
        if (excecao != null) {
        corpoMensagem.setText(Util.converterEmTexto(excecao));
        } else {
        corpoMensagem.setText("Testes executados com sucesso");
        }

        partes.addBodyPart(corpoMensagem);
        }

        private void anexarArquivo(Multipart partes, String arquivosDir,
        String relatorioDir) throws MessagingException {

        String arquivoZip = arquivosDir + File.separator + NOME_ZIP;
        File zip = Compactador.getInstance().compactarArquivos(arquivoZip,
        relatorioDir);
        if (zip != null) {
        anexoMensagem = new MimeBodyPart();
        DataSource dataSource = new FileDataSource(zip);
        anexoMensagem.setDataHandler(new DataHandler(dataSource));
        anexoMensagem.setFileName(zip.getName());
        partes.addBodyPart(anexoMensagem);
        }
        }

        public String getDestinatario() {
        ...
        }

        public String getTitulo() {
        ...
        }

        public String getTextoMensagem() {
        ...
        }

        public File getArquivoMensagem() {
        ...
        }
        }
        

Utilizando o TestNG

Agora que as classes possuem a testabilidade desejada, criaremos os casos de teste que exercitam o nosso Corretor Java. O testes serão criados no TestNG, um framework que vem ganhando bastante atenção dos desenvolvedores e testadores e já é considerado por muitos o sucessor do JUnit.

O TestNG compartilha diversos dos conceitos propostos pelo JUnit, mas inova com muitas características que facilitam o dia-a-dia do testador e potencializam os testes através do uso intensivo de anotações. O TestNG foi quem introduziu os conceitos de anotações e exceções, e influenciou fortemente o projeto do JUnit 4.

No TestNG os testes são especificados através da anotação @Test e não é necessário estender classes ou implementar interfaces específicas, de forma semelhante ao JUnit 4. O framework contém diversas anotações referentes à configuração dos testes, que permitem a execução de métodos específicos antes e depois da execução dos testes, suítes, grupos, métodos e classes.Essas anotações de configuração possuem prefixos @Before e @After, a exemplo de @BeforeClass e @AfterMethod.

Para completar, o TestNG emprega conceitos avançados de teste como dependência entre testes, agrupamento, dados de teste, métodos de teste com parâmetros, configuração de execução através de arquivos XML, etc. O framework possui integração com diversas IDEs e um utilitário que permite a conversão de casos de teste criados no JUnit.

Codificando

A Listagem 6 mostra o código da classe TestesPositivos, criada para exercitar o caminho básico do Caso de Uso. A classe emprega diversas funcionalidades do TestNG, e por isso vamos analisá-la passo a passo.

Listagem 6. Testes positivos
package org.codecompany.correcao.testes;

        ...

        public class TestesPositivos {

        private CorrecaoBean bean;

        @BeforeClass
        public void estadoInicial() {
        bean = new CorrecaoBean();
        }

        @Test
        @Parameters( { "dirPrincipal", "dirArquivosCorretos", "dirCasosTeste", "" })
        public void criarDiretorios(String dirPrincipal,
        String dirArquivosCorretos, String dirCasosTeste,
        ITestContext contexto) {

        // coloca os parâmetros no contexto
        contexto.setAttribute("dirPrincipal", dirPrincipal);
        contexto.setAttribute("dirArquivosCorretos", dirArquivosCorretos);
        contexto.setAttribute("dirCasosTeste", dirCasosTeste);

        // quantidade de subdiretorios antes
        int antes = TesteUtil.totalSubdir(dirPrincipal);

        // criação do diretório
        bean.criarDiretorios();

        // quantidade de subdiretorios depois
        int depois = TesteUtil.totalSubdir(dirPrincipal);

        // um novo diretório deve ter sido criado
        Assert.assertEquals(depois, antes + 1);
        }

        @Test(dataProvider = "arquivosCorretos", dependsOnMethods = { "criarDiretorios" })
        public void copiarArquivo(ITestContext contexto, File arquivo) {
        // o arquivo não existe ainda
        Assert.assertFalse(new File(bean.getSessaoDir() + File.separator
        + arquivo.getName()).exists());

        String conteudo = TesteUtil.recuperarConteudo(arquivo);
        try {
        bean.salvarArquivo(conteudo, arquivo.getName());
        } catch (IOException e) {
        Assert.fail();
        }

        // arquivo criado corretamente
        Assert.assertTrue(new File(bean.getSessaoDir() + File.separator
        + arquivo.getName()).exists());
        }

        @Test(dependsOnMethods = { "copiarArquivo" })
        public void compilarArquivo(ITestContext contexto) {
        // apenas o arquivo java e o diretório 'relatorio' existem
        Assert.assertTrue(new File(bean.getSessaoDir()).list().length == 2);

        ExecutorAnt executorAnt = new ExecutorAnt(bean.getSessaoDir());
        executorAnt.compilarArquivos();

        // coloca o executorAnt no contexto
        contexto.setAttribute("executorAnt", executorAnt);

        // arquivos .class criados
        Assert.assertTrue(new File(bean.getSessaoDir()).list().length > 2);
        }

        @Test(dependsOnMethods = { "compilarArquivo" })
        public void executarTestes(ITestContext contexto) {
        ExecutorAnt executorAnt = (ExecutorAnt) contexto
        .getAttribute("executorAnt");

        String dirCasosTeste = (String) contexto.getAttribute("dirCasosTeste");

        // nenhum arquivo de relatório
        Assert.assertTrue(bean.getRelatorioDir().list().length == 0);
        try {
        executorAnt.executarTestes(dirCasosTeste, bean.getRelatorioDir());
        // arquivos de relatório criados
        Assert.assertTrue(bean.getRelatorioDir().list().length > 0);
        } catch (ExecucaoTestesException e) {
        Assert.fail();
        }
        }

        @Test(dependsOnMethods = { "executarTestes" })
        public void enviarRelatorio(ITestContext contexto) {
        EnvioEmail envioEmail = new EnvioEmail("andre.dantas.rocha@uol.com.br",
        bean.getSessaoDir(), bean.getRelatorioDir().getAbsolutePath(), null);

        Assert.assertTrue(envioEmail.enviar());
        Assert.assertEquals(envioEmail.getDestinatario(),"andre.dantas.rocha@uol.com.br");
        Assert.assertEquals(envioEmail.getTextoMensagem(),"Testes executados com sucesso");
        Assert.assertEquals(envioEmail.getTitulo(),"Testes executados com sucesso");

        String esperado = (bean.getSessaoDir() + File.separator + "relatorio.zip").replace("/", "\\");
        String enviado = envioEmail.getArquivoMensagem().getAbsolutePath().replace("/", "\\");

        Assert.assertEquals(esperado, enviado);
        }

        @DataProvider(name = "arquivosCorretos")
        public Object[][] listaArquivosCorretos(ITestContext contexto) {
        String dirArquivosCorretos = (String) contexto
        .getAttribute("dirArquivosCorretos");
        File[] arquivos = TesteUtil.listaArquivosJava(dirArquivosCorretos);
        return new Object[][] { new Object[] { contexto, arquivos[0] } };
        }
        }
        

A classe TestesPositivos contém cinco métodos de teste, que testam cada um dos passos do fluxo básico: criarDiretorios(), copiarArquivo(), compilarArquivo(), executarTestes() e enviarRelatorio(). Como veremos adiante, os testes são dependentes e executados em seqüência, exercitando, portanto, a integração entre os diversos módulos que compõem o software.

O método mais simples da classe denomina-se estadoInicial(). Esse método é anotado com a anotação @BeforeClass, significando que será executado automaticamente pelo framework imediatamente antes dos métodos de teste da classe. O objetivo é configurar a instância para os testes que serão executados a seguir:

@BeforeClass
        public void estadoInicial() {
        bean = new CorrecaoBean();
        }
        

O método cria um objeto da classe CorrecaoBean e o armazena no atributo bean. É importante lembrar que apesar da classe CorrecaoBean ser um Message-Driven Bean, todos os testes são feitos sem a necessidade de um container EJB, o que torna essa tarefa bem mais simples (container Web e provedor JMS também não são necessários).

O método criarDiretorios() é um pouco mais incrementado e possui diversas características interessantes. A anotação @Test define que esse é um método de teste, e será executado automaticamente pelo TestNG. Um ponto notável é que o método possui parâmetros, algo incomum para aqueles acostumados com o JUnit.

Os parâmetros são preenchidos através de injeção de dependência, com auxílio da anotação @Parameters e do arquivo de configuração dos testes (testng.xml). Cada valor declarado na anotação deve ser ter um correspondente no testng.xml; os parâmetros são preenchidos automaticamente com os valores descritos nesse arquivo na ordem em que forem descritos no método. A Listagem 7 exibe o arquivo de configuração dos testes para esse exemplo:

@Test
        @Parameters( { "dirPrincipal", "dirArquivosCorretos", "dirCasosTeste", "" })
        public void criarDiretorios(String dirPrincipal, String dirArquivosCorretos,
        String dirCasosTeste, ITestContext contexto)
        
Listagem 7. Configuração para execução dos testes
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
        <suite name="CorrecaoTeste">
        <test verbose="2" name="TestesPositivos">

        <parameter name="dirPrincipal" value="C:/CORRECAO_ARQUIVOS"/>
        <parameter name="dirArquivosCorretos"
        value="C:/Andre/Projetos/CorrecaoTeste/resource/arquivos_corretos"/>

        <parameter name="dirCasosTeste" value="C:/CORRECAO_ARQUIVOS"/>

        <classes>
        <class name="org.codecompany.correcao.testes.TestesPositivos"/>
        </classes>
        </test>
        </suite>

Um dos parâmetros do método merece atenção especial: ITestContext. Essa interface armazena o contexto de execução dos testes e dá acesso a diversas informações interessantes, como os testes que já foram executados, quantidade de falhas, etc. Além disso, como esse objeto é compartilhado entre os métodos da classe, é possível utilizá-lo para passar informações entre os métodos de teste:

contexto.setAttribute("dirPrincipal", dirPrincipal);
        contexto.setAttribute("dirArquivosCorretos", dirArquivosCorretos);
        contexto.setAttribute("dirCasosTeste", dirCasosTeste);
        

O método copiarArquivo() também apresenta diversas configurações interessantes. Assim como no método criarDiretorios(), explicado anteriormente, esse método também possui parâmetros e o contexto de teste é injetado automaticamente pelo framework através do parâmetro contexto. É importante lembrar que o contexto armazena todos os atributos que foram adicionados no método anterior (criarDiretorios()).

@Test(dataProvider = "arquivosCorretos", dependsOnMethods = { "criarDiretorios" })
        public void copiarArquivo(ITestContext contexto, File arquivo)
        

As maiores novidades, no entanto, estão nos atributos da anotação @Test. O atributo dependsOnMethods especifica as dependências entre métodos de teste. Neste caso, por exemplo, o método copiarArquivo() só será executado se o teste criarDiretorios() tiver sido executado anteriormente com sucesso. Essa funcionalidade é muito útil no teste de integração e evita que os testes sejam executados caso suas pré-condições não tenham sido cumpridas.

O leitor mais atento deve estar se perguntando como os parâmetros contexto e arquivo são preenchidos, já que nenhuma anotação @Parameters foi especificada. O TestNG oferece uma alternativa que permite especificar dinamicamente os valores dos parâmetros de teste: a anotação @DataProvider.

Como o próprio nome diz, um DataProvider é um método responsável por fornecer um conjunto de dados para o teste. Qualquer método pode ser um provedor de dados, desde que seja anotado com @DataProvider e possua retorno do tipo Object[][] ou Iterator.

O provedor deve possuir um nome (neste caso, “arquivosCorretos”), que será utilizado para referenciá-lo no método utilizador. Também é possível utilizar o contexto de teste nos provedores, o que flexibiliza ainda mais sua codificação.

No código abaixo é exibida a implementação do nosso provedor. O provedor é utilizado para recuperar arquivos válidos (compiláveis e com lógica correta), que estão armazenados num determinado diretório. Todos os arquivos Java desse diretório são listados e a seguir colocados num array no formato [contexto, arquivo], que corresponde aos parâmetros do método copiarArquivo():

@DataProvider(name = "arquivosCorretos")
        public Object[][] listaArquivosCorretos(ITestContext contexto) {
        String dirArquivosCorretos = (String) contexto.getAttribute("dirArquivosCorretos");
        File[] arquivos = TesteUtil.listaArquivosJava(dirArquivosCorretos);
        return new Object[][] { new Object[] { contexto, arquivos[0] } };
        }
        

A lista de arquivos é passada automaticamente para o método copiarArquivo() e sua execução é realizada tantas vezes quanto forem os itens contidos na lista (nesse caso apenas um registro). Essa funcionalidade permite testar o mesmo método com uma grande quantidade de valores de parâmetros.

Os demais métodos da classe não apresentam novidades e não serão detalhados por questão de espaço. É possível visualizá-los através da Listagem 6.

testng.xml

O TestNG utiliza uma estratégia bastante interessante para configurar a execução dos casos de teste. Toda configuração é feita externamente através do arquivo testng.xml, o que permite alterar a configuração sem necessidade de recompilação das classes.

A Listagem 7 exibe a listagem do arquivo para o nosso exemplo. O formato do arquivo é bastante intuitivo, mas duas tags merecem destaque: parameter, que especifica o valor dos parâmetros que serão utilizados nos métodos de teste e classes, que especifica as classes de teste que serão executadas. O arquivo ainda possui diversas opções, como agrupamento de testes, pacotes, etc.

Executando os testes

Após a codificação das classes de teste e configuração do arquivo testng.xml, vamos executar os testes através do Eclipse. O TestNG oferece um plug-in que permite rodar os casos de teste diretamente nessa IDE, utilizando uma view semelhante àquela do JUnit.

A instalação do plug-in é bastante simples e pode ser feita a partir do site (vide referências). Uma vez instalado o plug-in, basta clicar com o botão direito sobre uma classe de teste e escolher a opção Run As > TestNG Test. Neste caso o TestNG criará o arquivo de configuração automaticamente.

A segunda opção é clicar com o botão direito sobre o arquivo testng.xml e selecionar a opção Run As > TestNG Suite. Nesse caso, todos os testes configurados no XMLserão executados. As Figuras 3 e 4 mostram o resultado da execução dos testes.

Execução dos testes a partir do Eclipse (resultados no console)
Figura 3. Execução dos testes a partir do Eclipse (resultados no console)
Execução dos testes a partir do Eclipse (resultados na emview/em do plug-in)
Figura 4. Execução dos testes a partir do Eclipse (resultados na view do plug-in)

Conclusões

Teste de software é um tema amplo e complexo, mas também interessante e desafiador. A automação e uso de ferramentas são premissas básicas do teste, mas sem um bom embasamento teórico nunca serão utilizadas em sua potencialidade.

O teste de integração possui o objetivo claro de testar a integração entre os módulos do software e pode oferecer uma visão geral da qualidade do produto. É importante ter em mente, no entanto, que essa fase de teste não substitui a fase de teste unitário ou teste de sistema, sendo mais um complemento.

O TestNG é um excelente framework, tanto para a fase de teste unitário quanto para a fase de teste de integração. Neste artigo abordamos apenas alguns dos recursos dessa poderosa ferramenta; vale à pena investir e estudá-la mais a fundo, pois os resultados serão garantidos.

Livros

The Art of Software Testing, Myers, G. J. (Prentice-Hall, 2004)
Uma das melhores referências sobre teste.

Introdução ao Teste de Software, Maldonado, J. C. e outros ( Campus / Elsevier, 2007)
Ótima referência sobre testes, escrita pelos principais pesquisadores brasileiros da área. O material inclui explicações detalhadas sobre as técnicas e os critérios de teste.

Next Generation Java Testing, Beust, C. e Suleiman, H. (Addison Wesley, 2007)
Referência completa sobre o TestNG, escrita pelo criador do framework.

Confira também