Desenvolvimento Orientado por Testes

Uma visão geral do Desenvolvimento Orientado por Testes.


O desenvolvimento orientado por testes (TDD) refere-se a um estilo de programação em que se escrevem testes para orientar a implementação do design do programa no código.

Um ou mais testes (em particular, testes unitários) são escritos antes de se programar. Os testes destinam-se a cobrir um aspeto do comportamento do programa, que pode estar centrado numa única função ou método. Escrever os testes é uma forma de transformar os requisitos do programa e a arquitetura geral num 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 novamente. Se os testes passarem, ou a implementação desse comportamento está concluída ou talvez ainda seja preciso criar mais testes. Se os testes não passarem, o código é depurado e os testes são executados outra vez. O ciclo de testar e programar repete-se até que todos os testes necessários passem; nessa altura, a implementação desse aspeto do comportamento do programa está concluída... por agora.

Refatoração

A refatoração é a reescrita de código para melhorar o seu design. Não é simplesmente reescrever código para corrigir erros. Por vezes, chama-se "refatoração" ao ato de modificar o código para o fazer passar nos testes. Embora modificar o código possa incluir melhorar o design como forma de passar nos testes, limitar-se a depurar não é necessariamente melhorar o design do código e, por isso, não é necessariamente refatoração.

O seguinte é um exemplo de depuração 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 seguinte é um exemplo de refatoração e, depois, de depuração:


# 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 track de Python do Exercism utiliza a metodologia TDD nos seus exercícios. Os testes unitários já estão escritos. O estudante pode consultar os testes para obter uma compreensão mais detalhada do que é necessário para que uma solução passe. Pode ser disponibilizado ao estudante um esqueleto de solução.

Resolver um teste falhado no Exercism no editor web

Quando um ou mais testes falham numa solução de Python, a tarefa ou tarefas correspondentes não terão um fundo verde. A primeira área de tarefa que falhou será expandida e o seu cabeçalho será parecido com

Task 1 Extract coordinates -

Clicar no sinal de menos recolhe a tarefa, para podermos ver as outras tarefas, mas por agora ficamos com esta.

Por baixo, haverá uma área Test expandida, parecida com

       Test 1                               ⌄
FAILED TisburyTreasure > get coordinate

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

Normalmente, Test 1 é uma espécie de modelo com uma secção de código para preparar os testes. Não tem informação sobre o teste ou testes específicos que falharam. Perto do fim, dirá que

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

Clicar no ⌄ recolhe o teste.

Por baixo, haverá um teste recolhido parecido com:

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

O aspeto 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 serão provavelmente apresentados numa secção de código. Os dados podem ser relativos a todos os testes desta tarefa. No fundo, na secção Test Failure, está o motivo específico pelo qual este teste falhou. Pode ser parecido com isto:

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

Neste caso concreto, indica que o valor devolvido, ['2A'], não era igual ao valor esperado, '2A'.

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

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

Se retirarmos os parênteses retos da lista (por exemplo, return record[1]) e voltarmos a executar os testes, os testes da Tarefa 1 passam.

Se uma ou mais tarefas continuarem a falhar, repete-se o processo acima com cada uma delas até todos os testes passarem.

Por vezes, os dados esperados e os dados devolvidos são demasiado extensos para caberem todos na secção Test Failure. Pode ser parecido com isto:

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.

Pode ainda haver dados suficientes para perceber qual é o problema. No caso acima, são devolvidas duas quebras de linha (por exemplo, \n\n(\'Brass Spyglass) quando só se espera uma (por exemplo, \n(\'Brass Spyglass).

Depois de todos os testes passarem

Parabéns! Todos os testes passaram. E agora? Podes publicar a solução já. Ou, agora que o código funciona, se quiseres refatorá-lo por algum motivo, podes modificar o código e submeter outra iteração. Se achas que o código podia estar melhor, mas não sabes como, podes pedir mentoria para a solução. Se houver um mentor disponível, ele pode contactar-te com ideias para outras abordagens à solução. Ao publicares a solução, podes permitir comentários, e outros estudantes podem aproveitar para publicar comentários ou fazer perguntas.

Otimizar o desempenho

Embora "a otimização prematura seja a raiz de todo o mal" (um ditado atribuído tanto a Tony Hoare como a Donald Knuth), chega um momento em que, mesmo funcionando a solução, se quer melhorar o seu desempenho. Um desses momentos pode ser quando a solução passa alguns testes mas excede o tempo limite noutros. Pode ser útil saber exatamente quanto tempo um trecho de código demora. O módulo timeit pode ser usado para medir o tempo de execução do código até 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 é efetivamente executado e cronometrado. O parâmetro number determina quantas vezes o código de stmt é 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 está incluído no tempo total. Quanto mais iterações o código de stmt executar, menos o tempo de setup contará por iteração. O parâmetro timer permite passar um Timer diferente do predefinido. O argumento predefinido do parâmetro timer é perf_counter, que deve ser suficiente para a maioria dos casos. O argumento predefinido do parâmetro number é 1_000_000. O parâmetro globals especifica um espaço de nomes no qual o código é executado. O argumento predefinido do parâmetro globals é None.

O seguinte é um exemplo de utilização do timeit para ver quanto tempo demora a determinar se uma frase contém todas as vogais inglesas:


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 demorou, em média, 4.965089999896008e-07 segundos por chamada (cerca de 497 nanossegundos por chamada).

O exemplo seguinte serve para ver se retirar a chamada a casefold da compreensão de listas poupa 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 demorou, em média, 4.923898000270128e-07 segundos por chamada (cerca de 492 nanossegundos por chamada.) Assim, retirar o casefold da compreensão de listas poupou 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 analisar o código; no entanto, não é tão granular, uma vez que só desce até durações de milissegundos.