Desenvolvimento Guiado por Testes

Uma visão geral do Desenvolvimento Guiado por Testes.


O desenvolvimento orientado a testes (TDD) é um estilo de programação em que os testes são escritos para orientar a implementação do design do programa no código.

Um ou mais testes (em especial os testes de unidade) são escritos antes de programar. Os testes devem cobrir um aspecto do comportamento do programa, que pode estar concentrado em uma única função ou método. Escrever os testes é uma forma de transformar os requisitos do programa e a arquitetura geral em um design específico da implementação. Os testes são executados e devem falhar, porque o código ainda não foi implementado. O código é implementado e os testes são executados de novo. Se os testes passarem, ou a implementação do comportamento está pronta, ou talvez ainda seja preciso criar mais testes. Se os testes não passarem, faça debug do código e execute os testes novamente. O ciclo de testar e programar se repete até que todos os testes necessários passem; nesse momento, a implementação daquele aspecto do comportamento do programa está pronta... por enquanto.

Refatoração

A refatoração é a reescrita do código para melhorar o design dele. Não é simplesmente reescrever o código para corrigir bugs. Às vezes, modificar o código só para fazê-lo passar nos testes é chamado de "refatoração". Embora modificar o código possa incluir melhorar o design como forma de passar nos testes, apenas fazer debug não é necessariamente melhorar o design do código e, portanto, não é necessariamente refatoração.

O exemplo a seguir é de debug sem refatoração:

# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
    # used multiply operator by mistake. It fails the tests.
    return x * y

# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
    return x + y

O exemplo a seguir mostra uma refatoração e, depois, um debug:


# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
    # Introduced multiply operator by mistake. It fails the tests. This is why we test.
    return old_cars * new_cars

# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
    return old_cars + new_cars

TDD e Python no Exercism

A trilha de Python do Exercism usa a metodologia TDD nos exercícios. Os testes de unidade já estão escritos. Você pode ver os testes para entender com mais detalhes o que é necessário para uma solução passar. Um stub de solução pode ser fornecido para você.

Resolvendo problemas com um teste que falhou no Exercism, no editor web

Quando um ou mais testes falham em uma solução de Python, a(s) tarefa(s) correspondente(s) não terá(ão) fundo verde. A primeira área de tarefa que falhou aparece expandida, e o cabeçalho dela será parecido com

Task 1 Extract coordinates -

Clicar no sinal de menos recolhe a tarefa, e assim você pode ver as outras, mas por enquanto vamos ficar com esta.

Abaixo, haverá uma área de teste expandida parecida com

       Test 1                               ⌄
FAILED TisburyTreasure > get coordinate

onde Tisbury Treasure indica o exercício e get_coordinate indica a função ou o método que falhou.

O Test 1 normalmente é uma espécie de modelo, com uma seção de código para preparar os testes. Ele não traz informações sobre o(s) teste(s) específico(s) que falhou(aram). Perto do final, ele vai dizer que

One or more variations of this test failed. Details can be found under each [variant#].

Clicar no ⌄ recolhe o teste.

Abaixo, haverá um teste recolhido parecido com:

       Test 2                                                    >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
       ("Scrimshaw Whale's Tooth", '2A'), result='2A')

A aparência varia conforme a largura definida para o painel da direita. Clicar no > expande o teste. Os dados de entrada e os dados do resultado esperado provavelmente aparecem em uma seção de código. Os dados podem ser de todos os testes desta tarefa. No final, na seção Test Failure, está o motivo específico pelo qual esse teste falhou. Pode ser algo assim:

AssertionError: ['2A'] != '2A'

Neste caso específico, isso indica que o valor retornado, ['2A'], não era igual ao valor esperado, '2A'.

Se olharmos o código de get_coordinate, vemos que ele está implementado assim

def get_coordinate(record):
    return [record[1]]

Se removermos os colchetes da lista (por exemplo, return record[1]) e executarmos os testes de novo, os testes da Tarefa 1 vão passar.

Se uma ou mais tarefas continuarem falhando, repita o processo acima com cada uma delas até que todos os testes passem.

Às vezes, os dados esperados e os dados retornados são grandes demais para caber todos na seção Test Failure. Pode ser algo assim:

AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.

Mesmo assim, pode haver dados suficientes para ver qual é o problema. No caso acima, há duas quebras de linha no retorno (por exemplo, \n\n(\'Brass Spyglass) quando só uma é esperada (por exemplo, \n(\'Brass Spyglass).

Depois que todos os testes passam

Parabéns! Todos os testes passaram. E agora? Você pode publicar a solução agora mesmo. Ou, agora que o código funciona, se você quiser refatorá-lo por qualquer motivo, pode modificar o código e enviar outra iteração. Se você acha que o código poderia ser melhor, mas não sabe como, pode pedir mentoria para a solução. Se houver um mentor disponível, ele pode entrar em contato com ideias de outras abordagens para a solução. Ao publicar sua solução, você pode permitir comentários, e outros estudantes podem aproveitar para comentar ou fazer perguntas.

Otimizando o desempenho

Embora "a otimização prematura seja a raiz de todos os males" (frase atribuída tanto a Tony Hoare quanto a Donald Knuth), chega um momento em que, mesmo com a solução funcionando, queremos melhorar o desempenho dela. Uma dessas situações pode ser quando a solução passa em alguns testes, mas estoura o tempo em outros. Pode ser útil saber exatamente quanto tempo um trecho de código está levando. O módulo timeit pode ser usado para medir o tempo de execução do código em durações muito pequenas. A função timeit pode receber até cinco argumentos: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None). O parâmetro stmt define o código que será executado e cronometrado. O parâmetro number determina quantas vezes o código de stmt será executado. O parâmetro setup define o código que é executado apenas uma vez, para preparar a execução do código de stmt. O tempo de execução do código de setup entra no tempo total. Quanto mais iterações o código de stmt rodar, menos o tempo de setup vai contar por iteração. O parâmetro timer permite passar um Timer diferente do padrão. O argumento padrão do parâmetro timer é perf_counter, que deve bastar na maioria dos casos. O argumento padrão do parâmetro number é 1_000_000. O parâmetro globals especifica um espaço de nomes no qual executar o código. O argumento padrão do parâmetro globals é None.

O exemplo a seguir usa timeit para ver quanto tempo leva para determinar se uma frase contém todas as vogais do inglês:


import timeit

# run one million times
loops = 1_000_000

# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)

Executar o código um milhão de vezes levou, em média, 4.965089999896008e-07 segundos por chamada (cerca de 497 nanossegundos por chamada).

O exemplo a seguir verifica se tirar a chamada de casefold de dentro da compreensão de lista economiza algum tempo:


import timeit

loops = 1_000_000

print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    sentence = sentence.casefold()
    return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)

Executar o código um milhão de vezes levou, em média, 4.923898000270128e-07 segundos por chamada (cerca de 492 nanossegundos por chamada.) Então, tirar o casefold de dentro da compreensão de lista economizou cerca de 5 nanossegundos por chamada, ou cerca de 5 milissegundos no total para um milhão de chamadas.

O cProfile também pode ser usado para fazer profiling do código; no entanto, ele não é tão granular, pois só chega a durações de milissegundos.