Wie strukturiere ich Tests, bei denen ein Test der Aufbau eines anderen Tests ist?

18

Ich teste die Integration eines Systems, indem ich nur die öffentlichen APIs verwende. Ich habe einen Test, der ungefähr so ​​aussieht:

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

Grundsätzlich versuche ich, den gesamten "Fluss" einer einzelnen Transaktion zu testen. Jeder Schritt im Ablauf hängt davon ab, ob der vorherige Schritt erfolgreich ist. Da ich mich auf die externe API beschränke, kann ich nicht einfach Werte in die Datenbank stecken.

Also, entweder habe ich eine wirklich lange Testmethode, die A macht; behaupten; B; behaupten; C; behaupten ... ", oder ich teile es in separate Testmethoden auf, wobei jede Testmethode die Ergebnisse des vorherigen Tests benötigt, bevor sie ihre Sache tun kann:

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.

Ich denke das riecht. Gibt es eine bessere Möglichkeit, diese Tests zu schreiben?

Roger Lipscombe
quelle

Antworten:

10

Wenn dieser Test häufig ausgeführt werden soll , konzentrieren sich Ihre Bedenken eher darauf, wie die Testergebnisse auf eine Weise dargestellt werden können , die für diejenigen geeignet ist , von denen erwartet wird, dass sie mit diesen Ergebnissen arbeiten.

Aus dieser Sicht testAllTheThingswirft eine riesige rote Fahne auf. Stellen Sie sich vor, jemand führt diesen Test stündlich oder noch häufiger durch (gegen eine fehlerhafte Codebasis natürlich, sonst hätte es keinen Grund, ihn erneut auszuführen) und sieht jedes Mal das Gleiche FAIL, ohne einen klaren Hinweis darauf, welche Phase fehlgeschlagen ist.

Separate Methoden sehen viel ansprechender aus, da die Ergebnisse von Wiederholungen (unter der Annahme eines stetigen Fortschritts bei der Behebung von Fehlern im Code) wie folgt aussehen könnten:

    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

Nebenbei bemerkt, in einem meiner vergangenen Projekte gab es so viele Wiederholungen abhängiger Tests, dass Benutzer sich sogar darüber beschwerten, dass sie nicht gewillt sind, zu einem späteren Zeitpunkt wiederholte erwartete Ausfälle zu sehen, die durch einen Fehler im vorherigen Stadium "ausgelöst" wurden. Sie sagten , dass dieser Müll macht es ihnen schwere Testergebnisse zu analysieren : „Wir wissen bereits , dass der Rest von Testdesign wird scheitern, nicht die Mühe uns zu wiederholen“ .

Infolgedessen mussten Testentwickler ihr Framework eventuell um einen zusätzlichen SKIPStatus erweitern und eine Funktion im Testmanager-Code hinzufügen, um die Ausführung abhängiger Tests abzubrechen, sowie eine Option, um SKIPped-Testergebnisse aus dem Bericht zu entfernen.

    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
Mücke
quelle
1
Wenn ich es lese, klingt es, als wäre es besser gewesen, einen Test All The Things zu schreiben, aber mit einem klaren Bericht darüber, wo es fehlgeschlagen ist.
Javier
2
@Javier klare Berichterstattung von wo es versagt klingt schön in der Theorie, aber in meiner Praxis , wenn Tests häufig ausgeführt werden, die Arbeit mit diesem stark lieber sieht stumm PASS-FAIL - Token
Schnake
7

Ich würde den Testcode vom Setup-Code trennen. Vielleicht:

# 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

Denken Sie daran, alle Zufallsinformationen , die erzeugt wird , muss in der Behauptung , falls einbezogen werden es nicht, sonst wird Ihr Test nicht reproduzierbar sein kann. Ich könnte sogar den verwendeten Zufallssamen aufzeichnen. Wenn ein zufälliger Fall fehlschlägt, fügen Sie diese spezifische Eingabe als hartcodierten Test hinzu, um eine Regression zu verhindern.

Infogulch
quelle
1
+1 für dich! Tests sind Code, und DRY gilt beim Testen genauso wie in der Produktion.
DougM
2

Nicht viel besser, aber Sie können zumindest den Setup-Code vom Asserting-Code trennen. Schreiben Sie eine separate Methode, die die gesamte Geschichte Schritt für Schritt erzählt, und legen Sie mithilfe eines Parameters fest, wie viele Schritte ausgeführt werden sollen. Dann kann jeder Test so etwas wie simulate 4oder sagen simulate 10und dann behaupten, was immer er testet.

Kilian Foth
quelle
1

Nun, ich könnte die Python-Syntax hier nicht durch "Luftkodierung" erhalten, aber ich denke, Sie haben die Idee: Sie können eine allgemeine Funktion wie diese implementieren:

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

Damit können Sie Ihre Tests wie folgt schreiben:

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

Natürlich ist es fraglich, ob der Lesbarkeitsverlust dieses Ansatzes es wert ist, ihn zu verwenden, aber er reduziert den Code des Boilerplates ein wenig.

Doc Brown
quelle