Técnicas de Teste: A Tabela de E/S

Thiago Araújo Silva
Traduzido por Thiago Araújo Silva

Se você trabalha com sistemas de banco de dados, com certeza já escreveu uma query SQL para obter registros em ordenações específicas pelo menos uma vez na sua carreira. Você pode até mesmo ter aplicado um ORDER BY em uma ou mais colunas, então como você testaria uma query dessas?

Vamos ver um exemplo bem comum escrito em Ruby:

it "fetches appointments sorted by date_time asc, provider first name asc, and city asc" do
  freeze_time do
    location1 = create(:location, city: "Atlanta")
    location2 = create(:location, city: "Nevada")

    provider_z = create(:provider, first_name: "Zed")
    provider_a = create(:provider, first_name: "Albert")

    appointment2 = create(:appointment, date_time: 5.hours.from_now)
    appointment3a = create(:appointment, date_time: 2.days.from_now)
    appointment1 = create(:appointment, date_time: 1.hour.from_now)
    appointment3d = create(
      :appointment,
      date_time: 2.days.from_now + 1.hour,
      provider: provider_z,
      location: location2
    )
    appointment3c = create(
      :appointment,
      date_time: 2.days.from_now + 1.hour,
      provider: provider_z,
      location: location1
    )
    appointment3b = create(
      :appointment,
      date_time: 2.days.from_now + 1.hour,
      provider: provider_a,
      location: location1
    )

    expected_sort_order = [
      appointment1,
      appointment2,
      appointment3a,
      appointment3b,
      appointment3c,
      appointment3d
    ]

    expect(AppointmentsQuery.call).to eq expected_sort_order
  end
end

“Appointments” poderia ser traduzido como “compromisso” ou “item da agenda”; “provider”, nesse contexto, poderia ser traduzido como “médico”; e “location” como “localização”. Vou me referir a eles neste artigo pelos nomes originais em inglês.

Neste exemplo criamos alguns registros no topo, e no rodapé temos uma expectativa (expect) de que os appointments serão puxados na ordem especificada pela descrição do teste:

  • O primeiro critério de ordenação é “date time” em ordem ascendente,
  • O segundo é “provider first name” em ordem ascendente,
  • O terceiro é “city” em ordem ascendente.

Esse teste funciona, mas tem deficiências bem graves – e é isso que vamos explorar mais abaixo.

As três qualidades de um bom teste

Um bom teste certamente tem mais qualidades, mas aqui focaremos nas três principais:

  1. Corretude
  2. Legibilidade
  3. Manutenabilidade

Corretude

Seria esse teste correto? Na minha opinião, sim, pois além de testar nossa funcionalidade, note que os appointments estão embaralhados para evitar falsos positivos. Se tivessem sido criados na mesma ordem informada na expectativa do nosso teste, e se o SELECT tivesse omitido a cláusula ORDER BY, nosso teste poderia ter passado mesmo com a implementação incorreta! O motivo é que na maioria dos casos a query devolve os registros na mesma ordem em que foram criados; só que a ordem real nesse caso é incerta, e depende, entre outros fatores, da ordem em que os registros foram criados no disco.

Outro aspecto positivo é que nós estamos congelando o tempo nesse teste com freeze_time para evitar que datas iguais se tornem acidentalmente diferentes por questão de milissegundos. Se a gente não fizesse isso, o risco de instabilidade na suíte de testes (e nesse teste em específico) seria bem alto.

Apesar desse teste cobrir a funcionalidade da nossa query, ele possui graves falhas: é difícil de entender, o que diminui a nossa confiança, e também muito fácil de quebrar devido à sua complexidade desnecessária. E isso nos leva ao fator “legibilidade”!

Legibilidade

As variáveis que referenciam appointments transmitem um senso de ordenação, que fica mais ou menos claro ao olhar para o array expected_sort_order, que contém a ordem final que representa o resultado do nosso teste.

Se a gente prestar ainda mais atenção, vamos perceber que appointment3a, appointment3b, appointment3c e appointment3d têm esse nome porque agrupam quatro registros pertinentes à mesma data (2.days.from_now + 1.hour). Consequentemente, 1, 2 e 3 delimitam três grupos diferentes de datas, enquanto a, b, c e d determinam a ordem esperada dentro do terceiro grupo de datas.

Mas tem um detalhe importante; talvez você tenha percebido a existência de um grupo implícito de registros dentro do terceiro grupo, pois o nosso ORDER BY trabalha com três colunas. Deveríamos renomear nossas variáveis para appointment3a0, appointment3a1, etc, de forma a deixar clara a existência da terceira coluna? Provavelmente não! Isso seria bem estranho e incomum, então não vale a pena.

Agora vamos à nossa pergunta: esse teste é legível? Talvez só um pouquinho. Mas é complicado, sujeito à interpretações erradas e não é muito bonito. O que quero dizer é que o setup é bem grande e existe muita poluição visual nas variáveis, o que prejudica o entendimento do que de fato está sendo testado. Ou seja, a legibilidade é prejudicada.

Manutenabilidade

Esse é o aspecto onde o nosso teste leva a menor nota. Dada a seguinte mensagem de erro, você entenderia o que há de errado e como consertar o problema?

1) AppointmentsQuery fetches appointments sorted by date_time asc, provider first name asc, and city asc
   Failure/Error: expect(AppointmentsQuery.call).to eq expected_sort_order

     expected: [#<Appointment id: 81, date_time: "2022-03-25 20:16:50.000000000 +0000", provider_id: 70, location_id...intment id: 82, date_time: "2022-03-27 20:16:50.000000000 +0000", provider_id: 66, location_id: 67>]
          got: #<ActiveRecord::Relation [#<Appointment id: 84, date_time: "2022-03-27 20:16:50.000000000 +0000", pro...ntment id: 81, date_time: "2022-03-25 20:16:50.000000000 +0000", provider_id: 70, location_id: 70>]>

     (compared using ==)

     Diff:
     @@ -1,7 +1,31 @@
     -[#<Appointment id: 81, date_time: "2022-03-25 20:16:50.000000000 +0000", provider_id: 70, location_id: 70>,
     - #<Appointment id: 79, date_time: "2022-03-26 00:16:50.000000000 +0000", provider_id: 68, location_id: 68>,
     - #<Appointment id: 80, date_time: "2022-03-27 19:16:50.000000000 +0000", provider_id: 69, location_id: 69>,
     - #<Appointment id: 84, date_time: "2022-03-27 20:16:50.000000000 +0000", provider_id: 67, location_id: 66>,
     - #<Appointment id: 83, date_time: "2022-03-27 20:16:50.000000000 +0000", provider_id: 66, location_id: 66>,
     - #<Appointment id: 82, date_time: "2022-03-27 20:16:50.000000000 +0000", provider_id: 66, location_id: 67>]
     +[#<Appointment:0x00007fe0adf708b0
     +  id: 84,
     +  date_time: Sun, 27 Mar 2022 20:16:50.000000000 UTC +00:00,
     +  provider_id: 67,
     +  location_id: 66>,
     + #<Appointment:0x00007fe0adf70770
     +  id: 83,
     +  date_time: Sun, 27 Mar 2022 20:16:50.000000000 UTC +00:00,
     +  provider_id: 66,
     +  location_id: 66>,
     + #<Appointment:0x00007fe0adf706a8

Restante da saída omitida, senão tomaria o restante do artigo

Interpretar essa falha seria bem difícil, e ainda pior por conta das datas relativas (por exemplo, 2.hours.from_now) e dos convidados misteriosos referentes às chaves estrangeiras. E desejo boa sorte no debugging se a ordenação for uma pequena parte da lógica de uma query complexa!

E se a gente quisesse introduzir outro appointment no setup para ser o segundo item da ordenação esperada? A gente teria que renomear todas as variáveis “appointment” abaixo dele: appointment2 para appointment3, appointment3a para appointment4a, e assim por diante.

Por fim, quaisquer mudanças feitas no código em seu estado atual poderiam criar problemas difíceis de debugar, ou pior, o teste poderia passar até mesmo se o SQL estivesse incorreto.

Esse teste é passível de manutenção? Acredito que não.

De volta ao básico: entrada e saída (E/S)

Vamos rebobinar a fita. Afinal, o que estamos testando aqui? Com certeza não são os objetos de appointment. Estamos enviando dados para um repositório além dos limites da nossa aplicação e obtendo os mesmos de volta, o que significa que estamos lidando com efeitos colaterais (side effects) em etapas discretas. Como não estamos acoplados ao formato de nenhum objeto em particular, isso torna o nosso ambiente de testes ainda mais flexível para os formatos tabulares que iremos obter mais abaixo. E o mais importante: é apenas entrada e saída.

Vamos organizar a nossa entrada embaralhada no formato de uma tabela de entrada:

date_time first_name city
2022-03-22 15:00 Zyler Texas
2022-03-24 10:00 Zyler Texas
2022-03-22 11:00 Zyler Texas
2022-03-24 11:00 Zed Nevada
2022-03-24 11:00 Zed Atlanta
2022-03-24 11:00 Albert Atlanta

Dada a tabela acima, fica bem fácil de ordenar os nossos registros, então vamos relembrar a ordem final:

  • Primeiro, por date_time ASC;
  • Segundo, por first_name ASC;
  • Terceiro, por city ASC.

Com essa informação, aqui está a nossa tabela de saída:

date_time first_name city
2022-03-22 11:00 Zyler Texas
2022-03-22 15:00 Zyler Texas
2022-03-24 10:00 Zyler Texas
2022-03-24 11:00 Albert Atlanta
2022-03-24 11:00 Zed Atlanta
2022-03-24 11:00 Zed Nevada

Essa especificação poderia ter sido escrito com papel e caneta!

Junto com a descrição do nosso teste, isso torna as coisas bem mais fáceis de entender. Como a gente poderia escrever o nosso teste o mais próximo possível dessa tabela?

Refatorando o nosso teste

O objetivo é que o nosso teste se pareça com uma tabela de entradas e saídas, então iremos pular os passos intermediários que nos levaram à solução final:

def create_appointments(rows)
  rows.each do |(date_time, first_name, city)|
    create(
      :appointment,
      date_time: date_time,
      location: create(:location, city: city),
      provider: create(:provider, first_name: first_name)
    )
  end
end

def appointments_query_result
  AppointmentsQuery.call.map do |appointment|
    [
      appointment.date_time.strftime("%Y-%m-%d %H:%M"),
      appointment.provider.first_name,
      appointment.location.city
    ]
  end
end

it "fetches appointments sorted by date_time asc, provider first name asc, and city asc" do
  create_appointments(
    [
      ["2022-03-22 15:00", "Zyler",  "Texas"],
      ["2022-03-24 10:00", "Zyler",  "Texas"],
      ["2022-03-22 11:00", "Zyler",  "Texas"],
      ["2022-03-24 11:00", "Zed",    "Nevada"],
      ["2022-03-24 11:00", "Zed",    "Atlanta"],
      ["2022-03-24 11:00", "Albert", "Atlanta"]
    ]
  )

  expect(appointments_query_result).to eq(
    [
      ["2022-03-22 11:00", "Zyler",  "Texas"],
      ["2022-03-22 15:00", "Zyler",  "Texas"],
      ["2022-03-24 10:00", "Zyler",  "Texas"],
      ["2022-03-24 11:00", "Albert", "Atlanta"],
      ["2022-03-24 11:00", "Zed",    "Atlanta"],
      ["2022-03-24 11:00", "Zed",    "Nevada"]
    ]
  )
end

Isso é bem mais fácil de entender do que a nossa primeira tentativa, e estamos até mesmo alinhando os itens das matrizes para que se pareçam com tabelas. Eu não faria isso com atribuições de variáveis, mas aqui a ideia é replicar uma tabela, literalmente.

Note que não precisamos especificar o nome das colunas, pois nesse caso são bem óbvias; é bem perceptível que a primeira coluna referencia datas e horários, que “Albert” é um nome e que “Atlanta” é uma cidade. Caso contrário, poderíamos ter usado hashes alinhados verticalmente ou um comentário no topo da tabela para simular um cabeçalho.

Por fim, não precisamos mais congelar o tempo com freeze_time pois estamos usando datas absolutas como dados puros, e não mais datas relativas.

Considerações finais

Aqui estão algumas considerações1 finais sobre a nossa refatoração:

  • Não existem nomes complicados ou convidados misteriosos (mystery guests);
  • Os helpers ajudam a remover a poluição da construção repetida de objetos;
  • Os layouts tabulares tornam a leitura mais fácil;
  • Usar dados brutos ao invés de variáveis tem três grandes benefícios:
    • É possível ver claramente porque a ordenação esperada no teste é o que é;
    • A clareza torna mais fácil identificar outros casos de teste não considerados;
    • É possível entender as mensagens de erro sem olhar para uma parede de arrays de objetos.

A maioria das considerações também são válidas para a técnica tabela de E/S no geral!

Eu realmente recomendo essa técnica quando você estiver lidando com dados tabulares e efeitos colaterais, mas não se limitando apenas à esses casos! De maneira geral, eu recomendaria não ter medo de usar dados brutos nos seus testes, pois duplicação de dados geralmente não é um problema porque clareza é mais importante do que repetição (DRY) quando se fala em testes.


  1. Quando eu fiz uma sessão de pair programming com o meu colega Louis nesse código, foi assim que ele resumiu os benefícios de transformar o código para o formato de tabela de E/S.