Testen der reinen Funktion auf Unionstyp, der an andere reine Funktionen delegiert

8

Angenommen, Sie haben eine Funktion, die einen Unionstyp annimmt und dann den Typ einschränkt und an eine von zwei anderen reinen Funktionen delegiert.

function foo(arg: string|number) {
    if (typeof arg === 'string') {
        return fnForString(arg)
    } else {
        return fnForNumber(arg)
    }
}

Nehmen wir an, dass fnForString()und fnForNumber()sind auch reine Funktionen, und sie wurden bereits selbst getestet.

Wie soll man testen foo()?

  • Sollten Sie die Tatsache, dass es an fnForString()und fnForNumber()als Implementierungsdetail delegiert, behandeln und die Tests für jeden von ihnen im Wesentlichen duplizieren, wenn Sie die Tests für schreiben foo()? Ist diese Wiederholung akzeptabel?
  • Sollten Sie Tests schreiben, die diesen foo()Delegierten "kennen" fnForString()und fnForNumber()z. B. indem Sie sie verspotten und überprüfen, ob sie an sie delegieren?
Samfrances
quelle
2
Sie haben Ihre eigene überladene Funktion mit fest codierten Abhängigkeiten erstellt. Eine andere Möglichkeit, diese Art von Polymorphismus (genauer gesagt Ad-hoc-Polymorphismus) zu erreichen, besteht darin, die Funktionsabhängigkeiten als Argument (Typverzeichnis) zu übergeben. Dann könnten Sie zu Testzwecken Scheinfunktionen verwenden.
Bob
Ok, aber das ist eher ein Fall von "wie man Verspottung erreicht" - also ja, Sie könnten die Funktionen übergeben oder eine Curry-Funktion haben usw. Aber meine Frage war eher auf der Ebene "wie man verspottet?" sondern "verspottet den richtigen Ansatz im Kontext reiner Funktionen?".
Samfrances

Antworten:

1

In einer idealen Welt würden Sie Beweise anstelle von Tests schreiben. Betrachten Sie beispielsweise die folgenden Funktionen.

const negate = (x: number): number => -x;

const reverse = (x: string): string => x.split("").reverse().join("");

const transform = (x: number|string): number|string => {
  switch (typeof x) {
  case "number": return negate(x);
  case "string": return reverse(x);
  }
};

Sagen Sie bitte, dass beweisen wollen transformangewendet zweimal ist idempotent , dh für alle gültigen Eingaben x, transform(transform(x))ist gleich x. Nun, Sie müssten zuerst beweisen, dass negateund reversezweimal angewendet sind idempotent. Nehmen wir nun an, dass es trivial ist, die Idempotenz von zu beweisen negateund reversezweimal anzuwenden, dh der Compiler kann es herausfinden. Somit haben wir die folgenden Deckspelzen .

const negateNegateIdempotent = (x: number): negate(negate(x))≡x => refl;

const reverseReverseIdempotent = (x: string): reverse(reverse(x))≡x => refl;

Wir können diese beiden Deckspelzen verwenden, um zu beweisen, dass dies transformwie folgt idempotent ist.

const transformTransformIdempotent = (x: number|string): transform(transform(x))≡x => {
  switch (typeof x) {
  case "number": return negateNegateIdempotent(x);
  case "string": return reverseReverseIdempotent(x);
  }
};

Hier ist viel los, also lasst es uns zusammenfassen.

  1. Ebenso wie a|bein Vereinigungstyp und a&bein Schnittpunkttyp a≡bein Gleichheitstyp ist.
  2. Ein Wert xeines Gleichheitstyps a≡bist ein Beweis für die Gleichheit von aund b.
  3. Wenn zwei Werte aund bnicht gleich sind, ist es unmöglich, einen Wert vom Typ zu konstruieren a≡b.
  4. Der Wert refl, kurz für Reflexivität , hat den Typ a≡a. Es ist der triviale Beweis dafür, dass ein Wert sich selbst gleich ist.
  5. Wir haben reflim Beweis von negateNegateIdempotentund verwendet reverseReverseIdempotent. Dies ist möglich, weil die Sätze so trivial sind, dass der Compiler sie automatisch beweisen kann.
  6. Wir verwenden die negateNegateIdempotentund reverseReverseIdempotentLemmas, um zu beweisen transformTransformIdempotent. Dies ist ein Beispiel für einen nicht trivialen Beweis.

Das Schreiben von Proofs hat den Vorteil, dass der Compiler den Proof überprüft. Wenn der Beweis falsch ist, kann das Programm check nicht eingeben und der Compiler gibt einen Fehler aus. Beweise sind aus zwei Gründen besser als Tests. Zunächst müssen Sie keine Testdaten erstellen. Es ist schwierig, Testdaten zu erstellen, die alle Randfälle behandeln. Zweitens werden Sie nicht versehentlich vergessen, Randfälle zu testen. Der Compiler gibt in diesem Fall einen Fehler aus.


Leider hat TypeScript keinen Gleichheitstyp, da es keine abhängigen Typen unterstützt, dh Typen, die von Werten abhängen. Daher können Sie keine Proofs in TypeScript schreiben. Sie können Proofs in abhängig typisierten funktionalen Programmiersprachen wie Agda schreiben .

Sie können jedoch Vorschläge in TypeScript schreiben.

const negateNegateIdempotent = (x: number): boolean => negate(negate(x)) === x;

const reverseReverseIdempotent = (x: string): boolean => reverse(reverse(x)) === x;

const transformTransformIdempotent = (x: number|string): boolean => {
  switch (typeof x) {
  case "number": return negateNegateIdempotent(x);
  case "string": return reverseReverseIdempotent(x);
  }
};

Sie können dann eine Bibliothek wie jsverify verwenden, um automatisch Testdaten für mehrere Testfälle zu generieren.

const jsc = require("jsverify");

jsc.assert(jsc.forall("number", transformTransformIdempotent)); // OK, passed 100 tests

jsc.assert(jsc.forall("string", transformTransformIdempotent)); // OK, passed 100 tests

Sie können auch anrufen jsc.forallmit , "number | string"aber ich kann nicht scheinen , um es an die Arbeit zu machen.


Also, um deine Fragen zu beantworten.

Wie soll man testen foo()?

Die funktionale Programmierung fördert das eigenschaftsbasierte Testen. Zum Beispiel habe ich getestet , die negate, reverseund transformFunktionen zweimal für idempotence angewandt. Wenn Sie eigenschaftsbasierten Tests folgen, sollten Ihre Satzfunktionen in ihrer Struktur den Funktionen ähneln, die Sie testen.

Sollten Sie die Tatsache, dass es an fnForString()und fnForNumber()als Implementierungsdetail delegiert, behandeln und die Tests für jeden von ihnen im Wesentlichen duplizieren, wenn Sie die Tests für schreiben foo()? Ist diese Wiederholung akzeptabel?

Ja, ist das akzeptabel? Sie können jedoch ganz auf das Testen verzichten fnForStringund fnForNumberweil die Tests für diese in den Tests für enthalten sind foo. Der Vollständigkeit halber würde ich jedoch empfehlen, alle Tests einzuschließen, auch wenn dadurch Redundanz eingeführt wird.

Sollten Sie Tests schreiben, die diesen foo()Delegierten "kennen" fnForString()und fnForNumber()z. B. indem Sie sie verspotten und überprüfen, ob sie an sie delegieren?

Die Aussagen, die Sie beim eigenschaftsbasierten Testen schreiben, folgen der Struktur der Funktionen, die Sie testen. Daher "kennen" sie die Abhängigkeiten, indem sie die Sätze der anderen getesteten Funktionen verwenden. Keine Notwendigkeit, sie zu verspotten. Sie müssen nur Dinge wie Netzwerkanrufe, Dateisystemaufrufe usw. verspotten.

Aadit M Shah
quelle
5

Die beste Lösung wäre nur zu testen foo.

fnForStringund fnForNumbersind ein Implementierungsdetail, das Sie möglicherweise in Zukunft ändern werden, ohne das Verhalten von unbedingt zu ändern foo. Wenn dies passiert, können Ihre Tests ohne Grund unterbrochen werden. Diese Art von Problem macht Ihren Test zu umfangreich und nutzlos.

Ihre Schnittstelle braucht foonur, testen Sie es einfach.

Wenn Sie diese Art von Test testen fnForStringund fnForNumbervon Ihren öffentlichen Schnittstellentests fernhalten müssen.

Dies ist meine Interpretation des folgenden von Kent Beck angegebenen Prinzips

Programmiertests sollten empfindlich gegenüber Verhaltensänderungen und unempfindlich gegenüber Strukturänderungen sein. Wenn das Verhalten des Programms aus Sicht eines Beobachters stabil ist, sollten sich keine Tests ändern.

Giuseppe Schembri
quelle
2

Kurze Antwort: Die Spezifikation einer Funktion bestimmt, wie sie getestet werden soll.

Lange Antwort:

Testen = Verwenden einer Reihe von Testfällen (hoffentlich repräsentativ für alle möglicherweise auftretenden Fälle), um zu überprüfen, ob eine Implementierung ihre Spezifikation erfüllt.

In dem Beispiel wird foo ohne Angabe angegeben, daher sollte man foo testen, indem man überhaupt nichts tut (oder höchstens einige alberne Tests, um die implizite Anforderung zu überprüfen, dass "foo auf die eine oder andere Weise endet").

Wenn die Spezifikation betriebsbereit ist wie "Diese Funktion gibt das Ergebnis der Anwendung von Argumenten auf fnForString oder fnForNumber je nach Art der Argumente zurück", ist das Verspotten der Delegaten (Option 2) der richtige Weg. Unabhängig davon, was mit fnForString / Number passiert, bleibt foo in Übereinstimmung mit seiner Spezifikation.

Wenn die Spezifikation nicht auf diese Weise von fnForType abhängt, ist die Wiederverwendung der Tests für fnFortype (Option 1) der richtige Weg (vorausgesetzt, diese Tests sind gut).

Beachten Sie, dass die Betriebsspezifikationen einen Großteil der üblichen Freiheit, eine Implementierung durch eine andere zu ersetzen (eine, die eleganter / lesbarer / effizienter / usw. ist), aufhebt. Sie sollten nur nach sorgfältiger Überlegung verwendet werden.

Koen AIS
quelle
1

Angenommen, fnForString () und fnForNumber () sind ebenfalls reine Funktionen und wurden bereits selbst getestet.

Nun , da Implementierungsdetails werden delegieren fnForString()und fnForNumber()für stringund numberjeweils zu testen es läuft darauf hinaus, nur nach unten stellen Sie sicher , dass fooAnrufe die richtige Funktion. Also ja, ich würde sie verspotten und sicherstellen, dass sie entsprechend aufgerufen werden.

foo("a string")
fnForNumberMock.hasNotBeenCalled()
fnForStringMock.hasBeenCalled()

Da fnForString()und fnForNumber()einzeln getestet wurden, wissen Sie, dass beim foo()Aufrufen die richtige Funktion aufgerufen wird und Sie wissen, dass die Funktion das tut, was sie tun soll.

foo sollte etwas zurückgeben. Sie könnten etwas von Ihren Mocks zurückgeben, jedes etwas anderes und sicherstellen, dass foo korrekt zurückkehrt (zum Beispiel, wenn Sie eine returnin Ihrer foo- Funktion vergessen haben ).

Und alle Dinge wurden abgedeckt.

jperl
quelle
0

Ich denke, es ist sinnlos, den Typ Ihrer Funktion zu testen. Das System kann dies alleine tun und es Ihnen ermöglichen, jedem der Objekttypen, die Sie interessieren, den gleichen Namen zu geben

Beispielcode

  //  fnForStringorNumber String Wrapper  
String.prototype.fnForStringorNumber = function() {
  return  this.repeat(3)
}
  //  fnForStringorNumber Number Wrapper  
Number.prototype.fnForStringorNumber = function() {
  return this *3
}

function foo( arg ) {
  return arg.fnForStringorNumber(4321)
}

console.log ( foo(1234) )       // 3702
console.log ( foo('abcd_') )   // abcd_abcd_abcd_

// or simply:
console.log ( (12).fnForStringorNumber() )     // 36
console.log ( 'xyz_'.fnForStringorNumber() )   // xyz_xyz_xyz_

Ich bin wahrscheinlich kein großer Theoretiker für Codierungstechniken, aber ich habe viel Code gepflegt. Ich denke, dass man die Wirksamkeit einer Kodierungsmethode nur in konkreten Fällen wirklich beurteilen kann, die Spekulation kann keinen Beweiswert haben.

Herr Jojo
quelle