Jak zbudować testy, w których jeden test jest konfiguracją innego testu?

18

Testuję integrację systemu, używając tylko publicznych interfejsów API. Mam test, który wygląda mniej więcej tak:

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...

Zasadniczo próbuję przetestować cały „przepływ” pojedynczej transakcji. Każdy krok w przepływie zależy od powodzenia poprzedniego kroku. Ponieważ ograniczam się do zewnętrznego interfejsu API, nie mogę po prostu wprowadzać wartości do bazy danych.

Tak więc albo mam jedną naprawdę długą metodę testową, która wykonuje `A; zapewniać; B; zapewniać; DO; asert ... ”, lub podzielę to na osobne metody testowe, gdzie każda metoda testowa potrzebuje wyników z poprzedniego testu, zanim będzie w stanie wykonać swoje zadanie:

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.

Myślę, że to pachnie. Czy istnieje lepszy sposób na napisanie tych testów?

Roger Lipscombe
źródło

Odpowiedzi:

10

Jeśli ten test ma być często uruchamiany , twoje obawy raczej koncentrują się na tym, jak prezentować wyniki testu w sposób wygodny dla osób, które mają pracować z tymi wynikami.

Z tej perspektywy testAllTheThingspodnosi ogromną czerwoną flagę. Wyobraź sobie, że ktoś uruchamia ten test co godzinę lub nawet częściej (przeciwko błędnej bazie kodu, w przeciwnym razie nie byłoby sensu ponownego uruchamiania) i widzi za każdym razem FAIL, bez wyraźnego wskazania, który etap się nie powiódł.

Oddzielne metody wyglądają znacznie atrakcyjniej, ponieważ wyniki ponownych uruchomień (zakładając stały postęp w usuwaniu błędów w kodzie) mogą wyglądać następująco:

    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

Na marginesie, w jednym z moich poprzednich projektów było tak wiele ponownych testów zależnych , że użytkownicy zaczęli nawet narzekać, że nie chcą widzieć powtarzających się oczekiwanych awarii na późniejszym etapie „wywołanych” przez awarię na wcześniejszym etapie. Powiedzieli, że te śmieci utrudniają im analizę wyników testu „wiemy już, że reszta zakończy się niepowodzeniem według projektu testu, nie zawracaj nam głowy powtarzaniem” .

W rezultacie programiści testów zostali ostatecznie zmuszeni do rozszerzenia swojej struktury o dodatkowy SKIPstatus i dodania funkcji w kodzie menedżera testów, aby przerwać wykonywanie testów zależnych oraz opcję upuszczenia SKIPwyników testu ped z raportu, aby wyglądało to tak:

    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
komar
źródło
1
kiedy to czytam, wydaje się, że lepiej byłoby napisać testAllTheThings, ale z jasnym raportem o tym, gdzie się nie udało.
Javier,
2
@Javier jasne zgłaszanie awarii nie brzmi dobrze w teorii, ale w mojej praktyce, za każdym razem, gdy testy są wykonywane często, osoby pracujące z tymi zdecydowanie wolą widzieć głupie tokeny
gnat
7

Oddzieliłbym kod testowy od kodu instalacyjnego. Być może:

# 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

Pamiętaj, że wszystkie generowane losowe informacje muszą być uwzględnione w twierdzeniu na wypadek, gdyby zakończyły się niepowodzeniem, w przeciwnym razie test może nie być odtwarzalny. Mogę nawet zapisać losowe użyte ziarno. Również za każdym razem, gdy przypadek nie powiedzie się, dodaj to konkretne wejście jako test zakodowany na stałe, aby zapobiec regresji.

infogulch
źródło
1
+1 dla Ciebie! Testy są kodem, a DRY stosuje się zarówno w testowaniu, jak i w produkcji.
DougM,
2

Nie wiele lepiej, ale możesz przynajmniej oddzielić kod instalacyjny od kodu potwierdzającego. Napisz oddzielną metodę, która opowiada całą historię krok po kroku, i weź parametr kontrolujący, ile kroków powinna ona podjąć. Następnie każdy test może powiedzieć coś podobnego simulate 4lub, simulate 10a następnie potwierdzić wszystko, co testuje.

Kilian Foth
źródło
1

Cóż, może nie rozumiem tutaj składni Pythona przez „kodowanie powietrzne”, ale myślę, że masz pomysł: możesz zaimplementować taką ogólną funkcję:

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

co pozwoli Ci napisać swoje testy w ten sposób:

  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)
  # ...

Oczywiście można dyskutować, czy warto skorzystać z utraty czytelności tego podejścia, ale nieco zmniejsza on kod płyty zbiorczej.

Doktor Brown
źródło