Como estruturar testes onde um teste é a instalação de outro teste?

18

Estou integrando os testes de um sistema, usando apenas as APIs públicas. Eu tenho um teste que se parece com isso:

def testAllTheThings():
  email = create_random_email()
  password = create_random_password()

  ok = account_signup(email, password)
  assert ok
  url = wait_for_confirmation_email()
  assert url
  ok = account_verify(url)
  assert ok

  token = get_auth_token(email, password)
  a = do_A(token)
  assert a
  b = do_B(token, a)
  assert b
  c = do_C(token, b)

  # ...and so on...

Basicamente, estou tentando testar todo o "fluxo" de uma única transação. Cada etapa do fluxo depende da etapa anterior ter êxito. Como estou me restringindo à API externa, não posso simplesmente colocar valores no banco de dados.

Então, ou eu tenho um método de teste realmente longo que faz `A; afirmar; B; afirmar; C; assert ... ", ou divido-o em métodos de teste separados, em que cada método de teste precisa dos resultados do teste anterior antes que ele possa fazer o seguinte:

def testAccountSignup():
  # etc.
  return email, password

def testAuthToken():
  email, password = testAccountSignup()
  token = get_auth_token(email, password)
  assert token
  return token

def testA():
  token = testAuthToken()
  a = do_A(token)
  # etc.

Eu acho que isso cheira. Existe uma maneira melhor de escrever esses testes?

Roger Lipscombe
fonte

Respostas:

10

Se esse teste se destina a ser executado com frequência , suas preocupações preferem se concentrar em como apresentar os resultados do teste de maneira conveniente para aqueles que se espera que trabalhem com esses resultados.

Nesta perspectiva, testAllTheThingslevanta uma enorme bandeira vermelha. Imagine alguém executando esse teste a cada hora ou até mais frequentemente (contra a base de código de buggy, é claro, caso contrário não haveria razão para executar novamente), e vendo todas as vezes a mesma coisa FAIL, sem uma indicação clara de qual estágio falhou.

Métodos separados parecem muito mais atraentes, porque os resultados de reexecuções (assumindo um progresso constante na correção de bugs no código) podem parecer com:

    FAIL FAIL FAIL FAIL
    PASS FAIL FAIL FAIL -- 1st stage fixed
    PASS FAIL FAIL FAIL
    PASS PASS FAIL FAIL -- 2nd stage fixed
    ....
    PASS PASS PASS PASS -- we're done

Nota lateral, em um dos meus projetos anteriores, houve tantas repetições de testes dependentes que os usuários começaram a reclamar por não querer ver falhas repetidas esperadas no estágio posterior "desencadeadas" por uma falha no anterior. Eles disseram que esse lixo dificulta a análise dos resultados dos testes "já sabemos que o resto falhará pelo design do teste, não nos incomode repetir" .

Como resultado, os desenvolvedores de teste acabaram sendo forçados a estender sua estrutura com SKIPstatus adicional e adicionar um recurso no código do gerenciador de testes para interromper a execução de testes dependentes e uma opção para descartar SKIPos resultados dos testes de pedestres no relatório, para que parecesse:

    FAIL -- the rest is skipped
    PASS FAIL -- 1st stage fixed, abort after 2nd test
    PASS FAIL
    PASS PASS FAIL -- 2nd stage fixed, abort after 3rd test
    ....
    PASS PASS PASS PASS -- we're done
mosquito
fonte
1
enquanto eu o leio, parece que seria melhor escrever um testAllTheThings, mas com relatórios claros de onde ele falhou.
18713 Javier
2
@Javier comunicação clara de onde ele falhou sons bom na teoria, mas na minha prática, quando os testes são executados com frequência, aqueles que trabalham com estes fortemente preferem ver mudo PASS-FALHA fichas
mosquito
7

Eu separaria o código de teste do código de instalação. Possivelmente:

# Setup
def accountSignup():
    email = create_random_email()
    password = create_random_password()

    ok = account_signup(email, password)
    url = wait_for_confirmation_email()
    verified = account_verify(url)
    return email, password, ok, url, verified

def authToken():
    email, password = accountSignup()[:2]
    token = get_auth_token(email, password)
    return token

def getA():
    token = authToken()
    a = do_A()
    return a

def getB():
    a = getA()
    b = do_B()
    return b

# Testing
def testAccountSignup():
    ok, url, verified = accountSignup()[2:]
    assert ok
    assert url
    assert verified

def testAuthToken():
    token = authToken()
    assert token

def testA():
    a = getA()
    assert a

def testB():
    b = getB()
    assert b

Lembre-se de que todas as informações aleatórias geradas devem ser incluídas na asserção, caso falhem; caso contrário, seu teste poderá não ser reproduzível. Eu posso até gravar a semente aleatória usada. Além disso, sempre que um caso aleatório falhar, adicione essa entrada específica como um teste codificado para impedir a regressão.

infogulch
fonte
1
+1 para você! Os testes são de código e o DRY se aplica tanto aos testes quanto à produção.
21413 DougM
2

Não é muito melhor, mas você pode pelo menos separar o código de instalação da afirmação do código. Escreva um método separado que conte toda a história passo a passo e escolha um parâmetro para controlar quantas etapas deve ser executada. Então cada teste pode dizer algo como simulate 4ou simulate 10e, em seguida, afirmar o que testar.

Kilian Foth
fonte
1

Bem, talvez eu não obtenha a sintaxe do Python aqui por "código de ar", mas acho que você entendeu: você pode implementar uma função geral como esta:

def asserted_call(create_random_email,*args):
    var result=create_random_email(*args)
    assert result
    return result

o que permitirá que você escreva seus testes assim:

  asserted_call(account_signup, email, password)
  url = asserted_call(wait_for_confirmation_email)
  asserted_call(account_verify,url)
  token = asserted_call(get_auth_token,email, password)
  # ...

Obviamente, é discutível se a perda de legibilidade dessa abordagem vale a pena usá-la, mas reduz um pouco o código padrão.

Doc Brown
fonte