Follow my new blog

Samstag, 7. Februar 2009

Semantische Kontrakte mittels Tests definieren

Im Kern komponentenorientierter Softwareentwicklung steht die Entkopplung von Codeeinheiten durch separate Kontrakte. Die Definition, was eine Codeeinheit tun soll, ist zu trennen von den möglicherweise vielen Implementationen dieser Definition. Typischerweise werden Kontrakte als Interfaces und Implemenationen als Klassen realisiert:

Kontrakt:

public interface ITaschenrechnerRechenwerk
{
    int Add(int a, int b);
   ...

Implementation:

public class TaschenrechnerRechenwerk : ITaschenrechnerRechenwerk
{
    public int Add(int a, int b) {...}
    ...

Gerade wenn solche Kontrakte in einer eigenen Assembly unabhängig von jeder Implementation vorliegen, lassen sie sich wunderbar an Dienstleister in nah und fern versenden. Der Dienstleister muss in seiner Implementation nur die Kontraktassembly referenzieren und das Kontrakt-Interface implementieren. Fertig ist die kontraktkonforme Realisation. Wenn Sie so eine Realisation geliefert bekommen, können Sie sicher sein, dass sie zum Rest Ihrer Anwendung passt, die sich auf den Kontrakt verlässt.

Oder? Ist es wirklich so einfach?

Syntaktischer Kontrakt

Formal ja. Durch das unveränderliche Interface, dass der Dienstleister nur implementieren muss, ist sichergestellt, dass seine Realisation kompatibel ist. Allerdings ist sie nur syntaktisch kompatibel. Eine Client-Klasse kann ohne Kenntnis dieser konkreten Realisierung implementiert werden:

Client:

public class TaschenrechnerClient
{
    public void Run(ITaschenrechnerRechenwerk tr)
    {
        double r = tr.Add(2,3);
        ...

}

ITaschenrechnerRechenwerk tr = ...;
TaschenrechnerClient tc = new TaschenrechnerClient();
tc.Run(tr);

image Ob tr eine Instanz von TaschenrechnerRechenwerk oder XYZ zugewiesen bekommt, ist TaschenrechnerClient egal. Das Interface stellt sicher, dass Client und Service-Implementation dieselbe "Sprache sprechen", dass sie zueinander passen. Mit einem Interface sorgen Sie nur dafür, dass eine Implementation "runde Hölzer" für "runde Löcher" bereitstellt. Das Interface beschreibt die Form einer Stecker-Steckdose-Kombination.

Semantischer Kontrakt

Was leistet aber ein Service, der die syntaktische Defintion erfüllt? Nur, weil ein Stecker in die Steckdose passt, heißt das noch lange nicht, dass an der Steckdose auch eine geeignete Spannung anliegt. Ein Service muss nicht nur so aussehen wie gewünscht, sondern auch leisten, was erwartet wird. Zu einer Komponente gehört deshalb nicht nur ein syntaktischer Kontrakt, sondern auch ein semantischer.

Der semantische Kontrakt baut auf dem syntaktischen auf und beschreibt die Erwartungen an den Service. Eine ITaschenrechnerRechenwerk-Implementation soll nicht nur eine Methode mit Namen Add(), double-Resultat und zwei double-Parametern bieten, sondern eben auch tun, was man von einer Methode mit Namen "Add" erwartet.

imageWie aber lassen Sie den Dienstleister wissen, was sie von einer Kontraktimplementation in semantischer Hinsicht erwarten? Trotz aller Bemühungen um formale Spezifikationen haben wir noch kein Mittel, um semantische Kontrakte so einfach wie syntaktische zu beschreiben. Sie werden also nicht umhin kommen, der Kontrakt-Assembly mit dem syntaktischen Kontrakt noch eine Beschreibung der Semantik beizulegen. Das könnten Sie in Form von Kommentaren in den Sourcen des syntaktischen Kontrakts tun oder mit einem separaten PDF-Dokument.

Und dann? Wenn Sie eine Implementation vom Dienstleister bekommen, was tun Sie dann? Wie stellen Sie sicher, dass die nicht nur den syntaktischen, sondern auch den semantischen Kontrakt erfüllt? Das müssen Sie testen. Oder Sie verlassen sich darauf, dass der Dienstleister es genügend getestet hat. Vielleicht liefert er ja einen Unit Test Testbericht mit und auch noch eine Angabe zur Codecoverrage des Tests.

Reicht das aber? Nein. Denn das sagt nur aus, dass der Code des Dienstleisters den Tests des Dienstleisters genügt. Darauf dürfen Sie sich nicht verlassen. Vielleicht hat der Dienstleister ja Ihre Beschreibung des semantischen Kontrakts falsch verstanden. Dann hat er wunderbare Tests gebaut, die alle fehlerfrei laufen - aber leiden testen sie das Falsche.

Tests als semantische Kontrakte

Wenn Sie sich wirklich auf einen Dienstleister verlassen wollen - der kann ja sogar im eigenen Team sitzen -, dann statten Sie ihn nicht nur mit einem unzweideutigen syntaktischen Kontrakt aus, den er nicht mehr interpretieren, sondern nur noch implementieren muss, sondern auch mit einem solchen semantischen. Auch ohne spezielle Spezifikationssprache geht das: mit Unit Tests. Es ist ganz einfach.

Als erstes definieren Sie weiterhin Ihren syntaktischen Kontrakt (s.o.).

Dann aber schreiben Sie keine länglichen Dokumente, um zu erklären, was eine Implementation können soll. Stattdessen schreiben Sie sofort Unit Tests, die alle Aspekte der Kontraktsemantik prüfen. Genau: Sie schreiben Unit Tests, ohne eine Implementation zu haben.  Das sollte kein Problem sein, denn zu einem syntaktischen Kontrakt, den Sie ersonnen haben, sollten Sie auch Erwartungen formulieren können, wie er sich zur Laufzeit verhält, wenn Sie ein seiner Implementationen mit diesen oder jenen Parametern aufrufen. Für den obigen Kontrakt kann das so aussehen:

Semantischer Kontrakt:

[TestFixture]
public class ExerciseITaschenrechnerRechenwerk
{
    ...

    [Test]
    public void Check_add()
    {
        ITaschenrechnerRechenwerk tr;
        ...

        Assert.AreEqual(5, tr.Add(2, 3));
    }
    ...

Ein ganz normaler Unit Test für eine Implementation von ITaschenrechnerRechenwerk. Der syntaktische Kontrakt ist durch das Interface vorgegeben, der semantische durch das Assert(): Wenn eine Funktion Add() vorhanden ist (Syntax), dann erwarten Sie, dass sie für die Parameter 2 und 3 das Ergebnis 5 zurückliefert. Das ist unzweideutig. Darüber muss kein Dienstleister mehr mit Ihnen diskutieren. Seine Implementation muss nur diese semantische Anforderung erfüllen. Punkt. Wenn er nicht versteht, was "Add" bedeutet, dann soll er halt bei Ihnen nachfragen. Ob sein Verständnis am Ende zu korrektem Code im Sinne Ihres Kontraktes geführt hat, beweist ein erfolgreicher Test.

Semantische Kontrakte ohne Implementation definieren

Klingt plausibel, oder? Wie können Sie nun aber vor jeder Implementation den semantischen Kontrakt formulieren? Mit Mock-Objekten. Ob obigen semantischen Kontrakt steht hinter tr keine "richtige" Implementation, sondern nur ein Mock-Objekt, dass die im Assert() formulierte Erwartung erfüllt. Nicht mehr, nicht weniger. Das kann mit Rhino Mock zum Beispiel so aussehen:

MockRepository mocks = new MockRepository();
tr = mocks.StrictMock<ITaschenrechnerRechenwerk>();
Expect.Call(tr.Add(2,3)).Return(5);
mocks.ReplayAll();

Ohne Frage funktioniert das bei Ihnen, wenn Sie den semantischen Kontrakt formulieren. Den Code können Sie auch dem Dienstleister geben - aber in Bezug auf dessen Implementation des Kontrakts ist er nutzlos. Er ist genauso passiv wie ein PDF-Dokument. Der Dienstleister kann ihn nur lesen, aber nicht auf seine Implementation anwenden.

Das Problem ist, dass Sie tr fest ein Mock-Objekt zuweisen. Wie soll das der Dienstleister durch seine Implementation des syntaktischen Kontrakts ersetzen? Denn nur dann kann schon er vor Auslieferung an Sie seine Implementation auch gegen den semantischen Kontrakt testen.

Als Lösung bietet sich eine kleine Factory-Methode an:

[TestFixture]
public class SemanticContract_ITaschenrechnerRechenwerk
{
    protected virtual
             ITaschenrechnerRechenwerk
             CreateInstanceToExercise(Func<ITaschenrechnerRechenwerk> factory)
    {
        return factory();
    }

    [Test]
    public void Check_add()
    {
        ITaschenrechnerRechenwerk tr;
        tr = CreateInstanceToExercise(delegate
                {
                    MockRepository mocks = new MockRepository();
                    tr = mocks.StrictMock<ITaschenrechnerRechenwerk>();
                    Expect.Call(tr.Add(2,3)).Return(5);
                    mocks.ReplayAll();
                    return tr;
                });

        Assert.AreEqual(5, tr.Add(2, 3));
    }
    ...

Die Factory-Methode liefert eine Instanz des zu prüfenden Kontrakts zurück. In Abwesenheit einer realen Implementation bekommt sie die als Parameter in Form einer weiteren Factory-Methode geliefert. Der anonyme Delegat erzeugt das Mock-Objekt, das bei Ihnen den semantischen Kontrakt garantiert erfüllt.

Wenn Sie eine Testklasse in dieser Weise formulieren, läuft sie wunderbar in Ihrem Testrunner (z.B. ReSharper). Ganz ohne eine echte Kontraktimplementation. Und sie können sie mit dem syntaktischen Kontrakt als separate Assembly an den Dienstleister schicken. Ob Sie dann noch zusätzlich Kommentare in den Kontrakt-Quellen oder ein PDF brauchen, müssen Sie beurteilen. Jedenfalls müssen Sie sich nicht mehr auf guten Glauben verlassen, ob der Dienstleister solche Dokumentation verstanden hat. Er muss nur darlegen, dass seine Implementation erfolgreich mit dem semantischen Kontrakt geprüft wurde.

Semantische Kontrakte gegen Implementation prüfen

Und wie prüft der Dienstleister seine Implementation gegen den semantischen Kontrakt? Oder wie prüfen Sie eine Implementation dagegen? Das ist ganz einfach: Sie müssen dem semantischen Kontrakt zur Laufzeit nur eine echte Implementation unterschieben statt der default Mock-Objekte. Dazu genügt aber die Ableitung einer neuen Testklasse von der des semantischen Kontrakts:

[TestFixture]
public class CheckTaschenrechnerRechenwerk
                             : SemanticContract_ITaschenrechnerRechenwerk
{
    protected override
             ITaschenrechnerRechenwerk
             CreateInstanceToExercise(Func<ITaschenrechnerRechenwerk> factory)
    {
        return new Dienstleister.TaschenrechnerRechenwerk();
    }
}

Diese Klasse kann der Dienstleister mit seinem Testrunner genauso ausführen wie Sie Ihre Testklasse des semantischen Kontrakts. Nur wird jetzt nicht gegen die Mock-Objekte geprüft, die die factory-Funktion erzeugen würde, sondern gegen die wahre Implementation. Bei diesem Akzeptanztest wird die factory-Funktion aus den Testmethoden einfach nicht berücksichtigt.

Zusammenfassung

Mehr ist nicht nötig für vollständige Kontraktdefinitionen für Komponenten:

  1. Definieren Sie einen syntaktischen Kontrakt in Form von Interfaces und/oder (abstrakten) Klassen in einer eigenen Assembly.
  2. Definieren Sie zum syntaktischen Kontrakt einen semantischen Kontrakt in Form von Tests in einer eigenen Assembly.
  3. Geben Sie dem Dienstleister für die Realisierung der Komponente sowohl den syntaktischen wie auch den semantischen Kontrakt und verlangen Sie, dass er nur an Sie ausliefert, wenn auch der semantische Kontrakt bei ihm fehlerfrei ausgeführt wird.

Da Sie zum Zeitpunkt der Definition des semantischen Kontrakts keine Implementation vorliegen haben, benutzen Sie Mock-Objekte, um Ihre Erwartungen zu erfüllen. Der Dienstleister muss diese Mock-Objekte zur Überprüfung seiner Implementation dann nur leicht ersetzen können. Mit einer abstrakten Factory-Methode wie oben gezeigt, ist das aber leicht.

So entwickeln Sie zwar noch nicht wirklich Test-driven, aber zumindest Test-first innerhalb von Contract-first: vor jede Implementation setzen Sie einen syntaktischen und (!) einen semantischen Kontrakt.

4 Kommentare:

Anonym hat gesagt…

Wieder ein guter Post. Aber kannst Du kurz darauf eingehen, warum dein Ansatz nur Test-First und nicht Test-Driven ist?

Ralf Westphal - One Man Think Tank hat gesagt…

@yogikamasa: test-first ist das nur, weil die tests vor der implementation geschrieben wurden.

test-driven wäre es gewesen, wenn ich die tests noch vor dem syntaktischen kontrakt geschrieben hätte. ja, um den syntaktischen kontrakt überhaupt erst zu definieren.

-ralf

oliver.boeff hat gesagt…

Geht man bei TDD nicht eher parallel vor, entstehen semantischer und syntaktischer Kontrakt nicht quasi gleichzeitig bzw. inspirieren sich gegenseitig?

Rein handwerklich benötigt man, um für den semantischen Kontrakt Tests schreiben zu können, den syntaktischen Kontrakt (tr.Add(2,3)) als Werkzeug "es zu tun".

Die Ziel-Idee eine Anforderung umzusetzen reift doch durch gemeinsames Anwenden beider Kontrakt-Aspekte.

Es schmeckt ein wenig wie das Henne/Ei-Problem (wobei die Henne [Semantik] eventuell etwas überwiegt bzw. näher an der der eigentlichen Anforderung gefühlt wird).

cu
boeffi

Ralf Westphal - One Man Think Tank hat gesagt…

@Boeffi: Klar, man geht parallel vor bei der Findung von sem. und synt. Kontrakt, die zusammen die Spezifikation einer Komponente sind.

Aber in jedem Augenblick konzentriert man sich auf nur einen. Anders gehts halt nicht.

Daraus kann sich natürlich eine Konsequenz für den anderen ergeben.

Und auch die Komponentenimplementation kann wieder zurückwirken. Also iterative Kontraktentwicklung.

-Ralf