Echtzeit-Unit-Tests - oder „wie man sich jetzt lustig macht“

9

Wenn Sie an einer Funktion arbeiten, die von der Zeit abhängt ... Wie organisieren Sie Unit-Tests? Wenn Ihre Unit-Testszenarien davon abhängen, wie Ihr Programm "jetzt" interpretiert, wie richten Sie sie ein?

Zweite Bearbeitung: Nach ein paar Tagen lesen Sie Ihre Erfahrungen

Ich kann sehen, dass sich die Techniken zur Bewältigung dieser Situation normalerweise um eines dieser drei Prinzipien drehen:

  • Hinzufügen (Hardcode) einer Abhängigkeit: Fügen Sie eine kleine Ebene über der Zeitfunktion / dem Objekt hinzu und rufen Sie Ihre Datums- / Uhrzeitfunktion immer über diese Ebene auf. Auf diese Weise können Sie die Zeit während der Testfälle steuern.
  • Verwenden Sie Mocks: Ihr Code bleibt genau gleich. In Ihren Tests ersetzen Sie das Zeitobjekt durch ein gefälschtes Zeitobjekt. Manchmal besteht die Lösung darin, das echte Zeitobjekt zu ändern, das von Ihrer Programmiersprache bereitgestellt wird.
  • Abhängigkeitsinjektion verwenden: Erstellen Sie Ihren Code so, dass jede Zeitreferenz als Parameter übergeben wird. Anschließend haben Sie während der Tests die Kontrolle über die Parameter.

Sprachspezifische Techniken (oder Bibliotheken) sind sehr willkommen und werden verbessert, wenn ein illustrativer Code hinzukommt. Am interessantesten ist dann, wie ähnliche Prinzipien auf jeder Plattform angewendet werden können. Und ja ... Wenn ich es sofort in PHP anwenden kann, besser als besser;)

Beginnen wir mit einem einfachen Beispiel: einer einfachen Buchungsanwendung.

Angenommen, wir haben eine JSON-API und zwei Nachrichten: eine Anforderungsnachricht und eine Bestätigungsnachricht. Ein Standardszenario sieht folgendermaßen aus:

  1. Sie stellen eine Anfrage. Sie erhalten eine Antwort mit einem Token. Die Ressource, die zur Erfüllung dieser Anforderung benötigt wird, wird vom System für 5 Minuten blockiert.
  2. Sie bestätigen eine durch das Token gekennzeichnete Anforderung. Wenn das Token innerhalb von 5 Minuten ausgestellt wurde, wird es akzeptiert (die Ressource ist noch verfügbar). Wenn mehr als 5 Minuten vergangen sind, müssen Sie eine neue Anfrage stellen (die Ressource wurde freigegeben. Sie müssen ihre Verfügbarkeit erneut überprüfen).

Hier kommt das entsprechende Testszenario:

  1. Ich mache eine Anfrage. Ich bestätige (sofort) mit dem Token, den ich erhalten habe. Meine Bestätigung wird akzeptiert.

  2. Ich mache eine Anfrage. Ich warte 3 Minuten. Ich bestätige mit dem Token, den ich erhalten habe. Meine Bestätigung wird akzeptiert.

  3. Ich mache eine Anfrage. Ich warte 6 Minuten. Ich bestätige mit dem Token, den ich erhalten habe. Meine Bestätigung wird abgelehnt.

Wie können wir diese Unit-Tests programmieren? Welche Architektur sollten wir verwenden, damit diese Funktionen testbar bleiben?

Bearbeiten Hinweis: Wenn wir eine Anfrage aufnehmen, wird die Zeit in einer Datenbank in einem Format gespeichert, das alle Informationen über Millisekunden verliert.

EXTRA - aber vielleicht etwas ausführlich: Hier kommen Details darüber, was ich bei meinen "Hausaufgaben" herausgefunden habe:

  1. Ich habe mein Feature mit einer Abhängigkeit von einer eigenen Zeitfunktion erstellt. Meine Funktion VirtualDateTime hat eine statische Methode get_time (), die ich dort aufrufe, wo ich neues DateTime () aufgerufen habe. Mit dieser Zeitfunktion kann ich simulieren und steuern, wie spät es ist, damit ich Tests erstellen kann wie: "Jetzt auf den 21. Januar 2014, 16:15 Uhr einstellen. Eine Anfrage stellen. 3 Minuten weitermachen. Bestätigung vornehmen." Dies funktioniert gut, auf Kosten der Abhängigkeit und "nicht so hübscher Code".

  2. Eine etwas stärker integrierte Lösung wäre, eine eigene myDateTime-Funktion zu erstellen, die DateTime um zusätzliche Funktionen für die "virtuelle Zeit" erweitert (vor allem jetzt auf das einstellen, was ich möchte). Dies macht den Code der ersten Lösung etwas eleganter (Verwendung von new myDateTime anstelle von new DateTime), ist jedoch sehr ähnlich: Ich muss mein Feature mit meiner eigenen Klasse erstellen und so eine Abhängigkeit erstellen.

  3. Ich habe darüber nachgedacht, die DateTime-Funktion zu hacken, damit sie mit meiner Abhängigkeit funktioniert, wenn ich sie brauche. Gibt es jedoch eine einfache und übersichtliche Möglichkeit, eine Klasse durch eine andere zu ersetzen? (Ich glaube, ich habe eine Antwort darauf bekommen: Siehe Namespace unten).

  4. In einer PHP-Umgebung, in der ich "Runkit" gelesen habe, kann ich dies möglicherweise dynamisch tun (die DateTime-Funktion hacken). Das Schöne ist, dass ich überprüfen kann, ob ich in einer Testumgebung ausgeführt werde, bevor ich etwas an DateTime ändere, und es in der Produktion unberührt lassen kann [1] . Dies klingt viel sicherer und sauberer als jeder manuelle DateTime-Hack.

  5. Abhängigkeitsinjektion einer Taktfunktion in jeder Klasse, die Zeit verwendet [2] . Ist das nicht übertrieben? Ich sehe, dass es in einigen Umgebungen sehr nützlich wird [5] . In diesem Fall gefällt es mir allerdings nicht so gut.

  6. Entfernen Sie die Zeitabhängigkeit in allen Funktionen [2] . Ist das immer machbar? (Weitere Beispiele finden Sie unten)

  7. Namespaces verwenden [2] [3] ? Das sieht ganz gut aus. Ich könnte DateTime mit meiner eigenen DateTime-Funktion verspotten, die \ DateTime erweitert ... Verwenden Sie DateTime :: setNow ("2014-01-21 16:15:00") und DateTime :: wait ("+ 3 Minuten"). Dies deckt ziemlich genau das ab, was ich brauche. Was ist, wenn wir die time () -Funktion verwenden? Oder andere Zeitfunktionen? Ich muss ihre Verwendung in meinem Originalcode immer noch vermeiden. Oder ich müsste sicherstellen, dass jede PHP-Zeitfunktion, die ich in meinem Code verwende, in meinen Tests überschrieben wird ... Gibt es eine Bibliothek, die genau dies tun würde?

  8. Ich habe nach einer Möglichkeit gesucht, die Systemzeit nur für einen Thread zu ändern. Es scheint, dass es keine gibt [4] . Das ist schade: Eine "einfache" PHP-Funktion zum "Einstellen der Zeit auf den 21. Januar 2014, 16h15 für diesen Thread" wäre eine großartige Funktion für diese Art von Tests.

  9. Ändern Sie das Datum und die Uhrzeit des Systems für den Test mit exec (). Dies kann funktionieren, wenn Sie keine Angst haben, andere Dinge auf dem Server durcheinander zu bringen. Und Sie müssen die Systemzeit zurücksetzen, nachdem Sie Ihren Test ausgeführt haben. Es kann in einigen Situationen den Trick machen, fühlt sich aber ziemlich "hacky" an.

Das scheint mir ein sehr normales Problem zu sein. Ich vermisse jedoch immer noch eine einfache und generische Methode, um damit umzugehen. Vielleicht habe ich etwas verpasst? Bitte teilen Sie Ihre Erfahrungen!

HINWEIS: In einigen anderen Situationen haben wir möglicherweise ähnliche Testanforderungen.

  • Jede Funktion, die mit einer Art Timeout funktioniert (z. B. Schachspiel?)

  • Verarbeiten einer Warteschlange, die Ereignisse zu einem bestimmten Zeitpunkt auslöst (im obigen Szenario könnten wir so weitermachen: Einen Tag vor Beginn der Reservierung möchte ich dem Benutzer eine E-Mail mit allen Details senden. Testszenario ...)

  • Sie möchten eine Testumgebung mit Daten einrichten, die in der Vergangenheit gesammelt wurden - Sie möchten es so sehen, als ob es jetzt wäre. [1]

1 /programming/3271735/simulate-different-server-datetimes-in-php

2 /programming/4221480/how-to-change-current-time-for-unit-testing-date-functions-in-php

3 http://www.schmengler-se.de/de/2011/03/php-mocking-built-in-functions-like-time-in-unit-tests/

4 /programming/3923848/change-todays-date-and-time-in-php

5 Zeitgebundener Code für Unit-Tests

mika
quelle
1
In Python (und ich stelle mir vor, dass andere dynamische Sprachen ähnliche Dinge haben) gibt es diese coole Bibliothek: github.com/spulec/freezegun
Ismail Badawi
2
Sobald Sie erkennen, dass dies nur ein Zeitpunkt ist, wird dies viel einfacher.
Wyatt Barnett
In vielen Fällen ist es einfacher, DateTime nowanstelle einer Uhr einen Wert an den Code zu übergeben.
CodesInChaos
@IsmailBadawi - das sieht in der Tat ziemlich cool aus. Auf diese Weise müssen Sie die Art und Weise, wie Sie codieren, in keiner Weise ändern. Sie richten Ihre Tests nur mithilfe der Bibliothek ein. Ist das richtig? Wie würde es mit unserem Buchungsbeispiel aussehen?
Mika
@ WyattBarnett Sicher. Der Moment, in dem ich die Kontrolle über diesen Zeitpunkt verliere, ist, wenn ich neues DateTime () ohne Parameter verwende. Halten Sie es für eine schlechte Angewohnheit, dies insgesamt in einer Klasse zu verwenden?
Mika

Antworten:

8

Ich werde Python-ähnlichen Pseudocode verwenden, der auf Ihren Testfällen basiert, um (hoffentlich) diese Antwort ein wenig zu erklären, anstatt nur zu entlarven. Kurz gesagt: Diese Antwort ist im Grunde nur ein Beispiel für eine kleine Abhängigkeitsinjektion / Abhängigkeitsinversion auf Einzelfunktionsebene , anstatt das Prinzip auf eine gesamte Klasse / ein ganzes Objekt anzuwenden.

Im Moment klingt es so, als ob Ihre Tests so strukturiert sind:

def test_3_minute_confirm():
    req = make_request()
    sleep(3 * 60)  # Baaaad
    assertTrue(confirm_request(req))

Mit Implementierungen etwas in der Art von:

def make_request():
    return { 'request_time': datetime.now(), }

def confirm_request(req):
    return req['request_time'] >= (datetime.now() - timedelta(minutes=5))

Vergessen Sie stattdessen "jetzt" und teilen Sie beiden Funktionen mit, wann sie verwendet werden sollen. Wenn es die meiste Zeit "jetzt" sein wird, können Sie eines der beiden wichtigsten Dinge tun, um den aufrufenden Code einfach zu halten:

  • Machen Sie die Zeit zu einem optionalen Argument, das standardmäßig "jetzt" lautet. In Python (und PHP, nachdem ich die Frage erneut gelesen habe) ist dies einfach - standardmäßig "Zeit" auf None( nullin PHP) und auf "Jetzt" setzen, wenn kein Wert angegeben wurde.
  • In anderen Sprachen ohne Standardargumente (oder in PHP, wenn es unhandlich wird) ist es möglicherweise einfacher, die Eingeweide der Funktionen in einen Helfer zu ziehen, der getestet wird.

Beispielsweise könnten die beiden oben in der zweiten Version neu geschriebenen Funktionen folgendermaßen aussehen:

def make_request():
    return make_request_at(datetime.now())

def make_request_at(when):
    return { 'request_time': when, }

def confirm_request(req):
    return confirm_request_at(req, datetime.now())

def confirm_request_at(req, check_time):
    return req['request_time'] >= (check_time - timedelta(minutes=5))

Auf diese Weise müssen vorhandene Teile des Codes nicht geändert werden, um "jetzt" zu übergeben, während die Teile, die Sie tatsächlich testen möchten , viel einfacher getestet werden können:

def test_3_minute_confirm():
    current_time = datetime.now()
    later_time = current_time + timedelta(minutes=3)

    req = make_request_at(current_time)
    assertTrue(confirm_request_at(req, later_time))

Dies schafft auch in Zukunft zusätzliche Flexibilität, sodass weniger Änderungen erforderlich sind, wenn wichtige Teile wiederverwendet werden müssen. Für zwei Beispiele, die in den Sinn kommen: Eine Warteschlange, in der Anforderungen möglicherweise zu spät erstellt werden müssen, aber dennoch die richtige Zeit verwenden, oder frühere Anforderungen im Rahmen von Datenüberprüfungen überprüft werden müssen.

Technisch gesehen könnte ein solches Vorausdenken gegen YAGNI verstoßen , aber wir bauen nicht wirklich auf diese theoretischen Fälle - diese Änderung dient nur dem einfacheren Testen, das diese theoretischen Fälle nur unterstützt.


Gibt es einen Grund, warum Sie eine dieser Lösungen einer anderen empfehlen würden?

Persönlich versuche ich, jede Art von Verspottung so weit wie möglich zu vermeiden und bevorzuge reine Funktionen wie oben. Auf diese Weise ist der getestete Code identisch mit dem Code, der außerhalb von Tests ausgeführt wird, anstatt dass wichtige Teile durch Mocks ersetzt werden.

Wir hatten ein oder zwei Regressionen, die seit einem guten Monat niemand mehr bemerkte, weil dieser bestimmte Teil des Codes in der Entwicklung nicht oft getestet wurde (wir haben nicht aktiv daran gearbeitet) und leicht defekt veröffentlicht wurde (es gab also keinen Fehler) Berichte), und die verwendeten Mocks versteckten einen Fehler in der Interaktion zwischen Hilfsfunktionen. Ein paar geringfügige Änderungen, sodass keine Verspottungen erforderlich waren und noch viel mehr leicht getestet werden konnten, einschließlich der Korrektur der Tests um diese Regression herum.

Ändern Sie das Datum und die Uhrzeit des Systems für den Test mit exec (). Dies kann funktionieren, wenn Sie keine Angst haben, andere Dinge auf dem Server durcheinander zu bringen.

Dies ist derjenige, der um jeden Preis für Unit-Tests vermieden werden sollte. Es gibt wirklich keine Möglichkeit zu wissen, welche Arten von Inkonsistenzen oder Rennbedingungen eingeführt werden können, um zeitweise Fehler zu verursachen (für nicht verwandte Anwendungen oder Mitarbeiter derselben Entwicklungsbox), und ein fehlgeschlagener / fehlerhafter Test stellt die Uhr möglicherweise nicht richtig zurück.

Izkata
quelle
Sehr guter Fall für die Abhängigkeitsinjektion. Vielen Dank für die Details, es hilft wirklich, das ganze Bild zu bekommen.
Mika
8

Für mein Geld ist die Abhängigkeit von einer Uhrenklasse der klarste Weg, um zeitabhängige Tests durchzuführen. In PHP kann es so einfach sein wie eine Klasse mit einer einzelnen Funktion, nowdie die aktuelle Zeit mit time()oder zurückgibt new DateTime().

Durch Injizieren dieser Abhängigkeit können Sie die Zeit in Ihren Tests manipulieren und die Echtzeit in der Produktion verwenden, ohne zu viel zusätzlichen Code schreiben zu müssen.

Andy Hunt
quelle
Dies ist, was derzeit in meiner Box läuft :) Und es hat ganz gut funktioniert. Natürlich müssen Sie Code schreiben, der über Ihre zusätzliche Klasse nachdenkt. Dies sind die Kosten.
Mika
1
@Mika: Der Overhead ist allerdings minimal, also nicht so schlimm. Ich werde jedoch sagen, dass meine eigentliche Präferenz darin besteht, erforderliche Daten \ Zeiten \ Datenzeiten als Parameter an eine Methode zu übergeben.
Andy Hunt
3

Als erstes müssen Sie die magischen Zahlen aus Ihrem Code entfernen. In diesem Fall Ihre "5 Minuten" magische Zahl. Sie müssen diese nicht nur zwangsläufig später ändern, wenn ein Kunde dies verlangt, sondern auch das Testen von Einheiten erheblich verbessern. Wer wartet 5 Minuten, bis der Test ausgeführt wird?

Zweitens, falls Sie dies noch nicht getan haben, verwenden Sie UTC für tatsächliche Zeitstempel. Dies verhindert Probleme mit der Sommerzeit und den meisten anderen Schluckaufzeiten.

Schalten Sie im Unit-Test die konfigurierte Dauer auf etwa 500 ms um und führen Sie Ihren normalen Test durch: Funktioniert dies auf eine Weise vor dem Timeout und auf eine andere Weise danach.

~ 1s ist für Komponententests immer noch ziemlich langsam, und Sie benötigen wahrscheinlich einen Schwellenwert, um die Maschinenleistung und Taktverschiebungen aufgrund von NTP (oder einer anderen Drift) zu berücksichtigen. Nach meiner Erfahrung ist dies jedoch der beste praktische Weg, um zeitbasierte Tests in den meisten Systemen durchzuführen. Einfach, schnell, zuverlässig. Die meisten anderen Ansätze (wie Sie festgestellt haben) erfordern eine Menge Arbeit und / oder sind furchtbar fehleranfällig.

Telastyn
quelle
Dies ist ein interessanter Ansatz. "5" steht hier für das Beispiel, ich stelle immer sicher, dass ich es in einer Konfigurationsdatei ändern kann :)
mika
@mika - Ich persönlich hasse Dateien. Sie spielen nicht gut mit Unit-Tests (die sehr gleichzeitig stattfinden sollten).
Telastyn
Ich neige dazu, Yaml ziemlich ausgiebig zu benutzen ...
Mika
3

Als ich TDD zum ersten Mal lernte, arbeitete ich in einer -Umgebung. Ich habe gelernt, dies zu tun, indem ich eine vollständige IoC-Umgebung habe, die auch eine enthält ITimeService, die für alle Zeitdienste verantwortlich ist und in Tests leicht verspottet werden kann.

Heutzutage arbeite ich in und die Stubbing-Zeit ist viel einfacher - Sie stubben einfach die Stub-Zeit :

describe 'some time based module' do

  before(:each) do
    @now = Time.now
    allow(Time).to receive(:now) { @now }
  end

  it 'times out after five minutes' do
    subject.do_something

    @now += 5.minutes

    expect { subject.do_another_thing }.to raise TimeoutError
  end
end
Uri Agassi
quelle
1
Ah, Rubinmagie! Einfach nur schön… Ich müsste jedoch zu meinem Buch zurückkehren, um den Code vollständig zu erfassen;) Stubs sind in vielen Fällen sehr elegant.
Mika
3

Verwenden Sie Microsoft Fakes. Anstatt Ihren Code an das Tool anzupassen, ändert das Tool Ihren Code zur Laufzeit und fügt anstelle der Standarduhr ein neues Zeitobjekt (das Sie in Ihrem Test definiert haben) ein.

Wenn Sie eine dynamische Sprache verwenden, ist Runkit genau das Gleiche.

Zum Beispiel mit Fakes:

[TestMethod]
public void MyDateProvider_ShouldReturnDateAsJanFirst1970()
{
    using (Microsoft.QualityTools.Testing.Fakes.ShimsContext.Create())
    {
        // Arrange: set up a shim to our own date object instead of System.DateTime.Now
        System.Fakes.ShimDateTime.NowGet = () => new DateTime(1970, 1, 1);

        //Act: call the method under test
        var curentDate = MyDateTimeProvider.GetTheCurrentDate();

        //Assert: that we have a moustache and a brown suit.
        Assert.IsTrue(curentDate == new DateTime(1970, 1, 1));
    }
}

public class MyDateTimeProvider
{
    public static DateTime GetTheCurrentDate()
    {
        return DateTime.Now;
    }
}

Wenn die Textmethode aufgerufen wird return DateTime.Now, wird die Zeit, die Sie injiziert haben, und nicht die aktuelle Zeit zurückgegeben.

gbjbaanb
quelle
Fakes, Runkit ... Am Ende hat jede Sprache ihren Weg, Stubs zu bauen. Könnten Sie ein kurzes Beispiel posten, damit wir ein Gefühl dafür bekommen, wie es geht?
Mika
3

Ich habe die Namespaces-Option in PHP untersucht. Namespaces bieten uns die Möglichkeit, der DateTime-Funktion ein Testverhalten hinzuzufügen. Somit bleiben unsere Originalobjekte unberührt.

Zuerst richten wir ein gefälschtes DateTime-Objekt in unserem Namespace ein, damit wir in unseren Tests verwalten können, welche Zeit als "jetzt" betrachtet wird.

In unseren Tests können wir diese Zeit dann über die statischen Funktionen DateTime :: set_now ($ my_time) und DateTime :: modify_now ("etwas Zeit hinzufügen") verwalten.

Zuerst kommt ein gefälschtes Buchungsobjekt . Dieses Objekt möchten wir testen - beobachten Sie die Verwendung von new DateTime () ohne Parameter an zwei Stellen. Ein kurzer Überblick:

<?php

namespace BookingNamespace;
/**
* Simulate expiration process with two public methods:
*      - book will return a token
*      - confirm will take a token, and return
*                       -> true if called within 5 minutes of corresponding book call
*                       -> false if more than 5 minutes have passed
*/
class MyBookingObject
{
    ...

    private function init_token($token){ $this->tokens[$token] = new DateTime(); }

    ...

    private function has_timed_out(DateTime $booking_time)
    {
            $now = new DateTime();
            ....
    }
}

Unser Datetime - Objekt überschreibt die Kerndatetime - Objekt sieht aus wie diese . Alle Funktionsaufrufe bleiben unverändert. Wir "haken" nur den Konstruktor, um unser Verhalten "Jetzt ist wann immer ich wähle" zu simulieren. Am wichtigsten,

namespace BookingNamespace;

/**
 * extend DateTime functionalities with static functions so that we are able 
 * to define what should be considered to be "now"
 *   - set_now allows to define "now"
 *   - modify_now applies the modify function on what is defined as "now"
 *   - reset is for going back to original DateTime behaviour
 *  
 * @author mika
 */
 class DateTime extends \DateTime
 {
     private static $fake_time=null;

     ...

     public function __construct($time = "now", DateTimeZone $timezone = null)
     {
          if($this->is_virtual()&&$this->is_relative_date($time))
          {
           ....
          }
          else
            parent::__construct($time, $timezone);
     }
 }

Unsere Tests sind dann sehr einfach. So geht es mit PHP-Einheit (Vollversion hier ):

....
require_once "DateTime.class.php"; // Override DateTime in current namespace
....

class MyBookingObjectTest extends \PHPUnit_Framework_TestCase
{
    ....

    /**
     * Create test subject before test
     */
    protected function setUp()
    {
        ....
        DateTime::set_now("2014-04-02 16:15");
        ....
    }

   ...

    public function testBookAndConfirmThreeMinutesLater()
    {
        $token = $this->booking_object->book();
        DateTime::modify_now("+3 minutes");
        $this->assertTrue($this->booking_object->confirm($token));
    }

    ...
}

Diese Struktur kann überall dort problemlos reproduziert werden, wo ich "jetzt" manuell verwalten muss: Ich muss nur das neue DateTime-Objekt einschließen und seine statischen Methoden in meinen Tests verwenden.

mika
quelle
Das Festlegen einer verspotteten DateTime-Instanz zum Testen der Zielklasse sollte ein besserer Plan sein, erfordert jedoch eine kleine Änderung new Datetimeder Zielklasse : Geben Sie eine separate Methode ein, um verspottbar zu sein.
Fwolf