Eine der meistgenutzten Testbibliotheken im Scala-Ökosystem ist Scalacheck. Das Team für funktionale Programmierung bei Xebia hat sich schon immer für dieses ausgereifte Tool eingesetzt. Wir haben bereits früher Inhalte zu diesem Thema veröffentlicht und bieten derzeit einen entsprechenden Kurs über
In diesem Beitrag möchte ich Ihnen etwas zeigen, mit dem ich mich in letzter Zeit beschäftigt habe: zustandsorientiertes Testen. Diese Technik kann als eine nicht-formale Anpassung der Modellprüfung betrachtet werden. Stateful Testing ist nützlich, wenn Sie Systeme überprüfen wollen, die sich über eine Reihe von Befehlen hinweg unterschiedlich verhalten, z.B. in einer zeitlichen Linie.
Eine kurze Einführung in eigenschaftsbasierte Tests
Bevor wir uns mit dem Testen zustandsabhängiger Systeme befassen, führen wir Sie in das eigenschaftsbasierte Testen ein (Sie können diesen Abschnitt gerne überspringen, wenn Sie bereits damit vertraut sind). Der erste Schritt besteht darin, nicht mehr an Tests, sondern an Eigenschaften zu denken. Die Vorstellung, dass es sich bei eigenschaftsbasierten Tests nur um Tests mit Zufallsdaten handelt, ist zu eng gefasst. Auf diese Weise überprüfen wir, ob ein bestimmter Teil unserer Anwendung (oder ein System, eine Funktion oder was auch immer) eine bestimmte Eigenschaft aufweist. Das kann eine schwierige Aufgabe sein, denn Eigenschaften müssen deterministisch sein.
Lassen Sie uns als konkretes Beispiel einen Unit-Test mit einem eigenschaftsbasierten Test für Code vergleichen, der mit Zahlen zwischen 0 und 100 korrekt funktionieren soll. Eine gute Abdeckung mit Unit-Tests erfordert mindestens die folgenden Fälle:
- Ein Wert von 0 sollte erfolgreich sein
- Ein Wert von 100 sollte erfolgreich sein
- Ein Wert von -1 sollte fehlschlagen
- Ein Wert von 101 sollte fehlschlagen
Etwas, das nicht näher an den Grenzen liegt, ist auch eine gute Praxis. Bei eigenschaftsbasiert denken wir über Eigenschaften nach, so dass zwei Eigenschaften ausreichen:
für alle Zahlen zwischen 0 und 100 einschließlich sollte der Test erfolgreich sein
für alle Zahlen , die nicht zwischen 0 und 100 einschließlich liegen sollte der Test fehlschlagen
Das Framework generiert Werte, die nicht völlig zufällig sind, sondern auch solche, die näher an den Grenzen oder Limits liegen (wie die maximale Ganzzahl). Wie Sie sich vorstellen können, bringt das eigenschaftsbasierte Testen zahlreiche Vorteile mit sich, von denen einige der wichtigsten sind:
- Ändert die Art und Weise, wie Sie Ihr System und die Tests modellieren, indem Sie mehr über Eigenschaften nachdenken und darüber, wie sich Ihre Geschäftslogik bei Eingaben, die verschiedene Kriterien erfüllen, verhalten sollte.
- Die generierten Daten sind nicht auf feste Werte beschränkt; jeder Wert, der die Vorbedingung erfüllt, kann ein Kandidat für die Eingabe sein. Folglich enthalten die Daten auch extreme Werte, wie leere Zeichenketten oder die maximale Ganzzahl, und schwer zu identifizierende Werte, wie spezielle Unicode-Zeichen. Bei der Entwicklung von Unit-Tests ist es schwierig, an diese Typen zu denken.
- Verkleinert die Eingaben, wenn die Eigenschaft fehlschlägt, so dass Benutzer freundlichere Gegenbeispiele für ihre fehlgeschlagenen Tests erhalten können.
- Sie sind reproduzierbar. Die eigenschaftsbasierten Frameworks generieren Daten unter Verwendung eines Seeds, der im Falle eines Fehlers gemeinsam genutzt wird, und Benutzer können diesen Seed an die Eigenschaft übergeben, um den Test mit denselben generierten Daten durchzuführen.
Es ist erwähnenswert, dass eigenschaftsbasierte Tests die Unit-Tests nicht vollständig ersetzen. Es ist ein anderer Ansatz, der für einige Anwendungsfälle besser geeignet ist, aber nicht für alle.
In Scala hat sich Scalacheck zu einem De-facto-Standard für eigenschaftsbasiertes Testen entwickelt. Dementsprechend gibt es hervorragende Ressourcen (insbesondere Vorträge), die die Grundlagen und Funktionalitäten abdecken. Einige von ihnen sind:
-
Eine Einführung in Scalacheck von Noel Markham (ein bisschen alt, aber immer noch aktuell),
-
Eigenschaftsbasiertes Testen - lassen Sie Ihre Testbibliothek für Sie arbeiten von Magda Stozek.
Modell und Befehle
Sehen wir uns nun an, was zustandsabhängige Tests sind und wie wir Scalacheck verwenden können, um diese speziellen Arten von eigenschaftsbasierten Tests zu entwickeln.
Auf der Grundlage eines vom Benutzer definierten Modells, das dem realen System am nächsten kommt, generiert das Framework eine Reihe von Befehlen, die den Ausführungsfluss darstellen, führt das System gegen diesen Fluss aus und vergleicht das System mit dem Modell.
In diesem Sinne können wir drei Hauptkomponenten von zustandsabhängigen Tests identifizieren:
- Das Modell
- Die Befehle Sequenzerstellung
- Die Validierung
Lassen Sie uns ein einfaches Beispiel verwenden, das wir später mit Scalacheck umsetzen werden. Stellen Sie sich vor, wir haben eine N + 1 Element einfügen, sollte das erste eingefügte Element verworfen werden.
trait SizedQueue[A] {
def push(a: A): Unit
def pop: Option[A]
def clear(): Unit
}
Die Operationen, die unsere große Warteschlange akzeptiert, sind "Push", "Pop" und "Clear". Wir haben uns für diese drei Befehle entschieden, um die Übung einfach zu halten.
Das Modell besteht aus zwei Teilen: einem Zustand und den Operationen, die diesen Zustand auf der Grundlage der angegebenen Befehle aktualisieren sollen. Denken Sie daran, dass wir das Modell als einen Zustand definiert haben, der dem tatsächlichen System am nächsten kommt, so dass wir für den Zustand unseres Beispiels eine einfache
Die Befehlssequenz wird aus der Liste der Befehle mit ihren jeweiligen Wertgeneratoren und Vorbedingungen erstellt. Die Vorbedingungen sind ein wesentliches Element, das entscheidet, ob der Befehl in Bezug auf den aktuellen Zustand sinnvoll ist.
In unserem Beispiel müssen wir Sequenzen der drei Befehle mit unterschiedlicher Häufigkeit erzeugen. Wir könnten einige Vorbedingungen definieren. Wenden Sie zum Beispiel keinen "Clear" an, wenn der Status leer ist. Fortgeschrittenere Anwendungsfälle erfordern komplexere Vorbedingungen.
Schließlich sollte die Validierung die Überprüfung des Systems anhand des Modells berücksichtigen. In diesem Fall müssen wir Nachbedingungen festlegen, um die Ergebnisse anhand des aktuellen Zustands zu überprüfen.
Ausführungsmodell
Die meisten Frameworks, die zustandsabhängige Tests implementieren, verwenden ein zweiphasiges Ausführungsmodell. In der ersten Phase verwendet das Framework das Modell und den Befehlssequenzgenerator, um eine Liste potenzieller Befehle zu erstellen. Mit Hilfe von Vorbedingungen werden Befehle verworfen und neue generiert.
In der zweiten Phase prüft das Framework das System mit dem angegebenen Zustand und den Nachbedingungen. Die Vorbedingungen werden erneut geprüft, was zu einem Fehlschlag in dieser Phase führt.

Glücklicherweise bietet Scalacheck eine einfache API für die Modellierung von zustandsabhängigen Tests auf der Grundlage der oben genannten Konzepte. Alles befindet sich in einer Datei, Commands.scalamit ausgezeichneter Dokumentation.
Scalacheck verwendet das Konzept der "Befehle", die ein aggregiertes Modell für die Befehle + Prä-Bedingungen + Post-Bedingungen darstellen. Um unseren zustandsabhängigen Eigenschaftstest zu erstellen, müssen wir Folgendes tun:
extendsdieCommandsEigenschaft- Definieren Sie unser
Commands - Implementieren Sie die Methoden zum Initialisieren des Systems, zum Erzeugen von Anfangszuständen, zum Abreißen und zum Erzeugen von Befehlen
Commands werden mit zwei Typen definiert: State, die das Modell darstellen, und Sut, das zu testende System (SUT).
Lassen Sie uns die Commands für unser SizedQueue Beispiel implementieren. Wir verwenden eine capacity Parameter zu unserem Status hinzu, um verschiedene maximale Größen der Warteschlange zu testen.
object SizedQueueCommands extends Commands {
case class TestState(capacity: Int, internal: List[Int])
override type State = TestState
override type Sut = SizedQueue[Int]
Jetzt ist es an der Zeit, einige Methoden zu implementieren. Wir beginnen mit den Generatoren für den Anfangszustand und dem SUT-Initialisierer und -Zerstörer.
override def genInitialState: Gen[State] = Gen.posNum[Int].map(TestState(_, Nil))
override def newSut(state: State): SizedQueue[Int] = SizedQueue.of(state.capacity)
override def destroySut(sut: SizedQueue[Int]): Unit = {}
Unser Anfangszustand ist eine zufällige positive int und eine leere Liste. Wir lassen die Initialisierung von Warteschlangen neuer Größe mit vorhandenen Daten nicht zu. Dann erstellen wir in destroySut keine Aktion durch, aber hier können Sie z.B. Ressourcen freigeben.
Jetzt werden wir ein paar spezielle Methoden in Scalacheck-Befehle implementieren:
override def canCreateNewSut(
newState: State,
initSuts: Iterable[State],
runningSuts: Iterable[SizedQueue[Int]]
): Boolean = true
override def initialPreCondition(state: State): Boolean = state.internal.isEmpty
canCreateNewSut ermöglichen es uns, die Anzahl der koexistierenden Sut Instanzen zu begrenzen. Andererseits wird initialPreCondition während der Schrumpfungsphase verwendet, um festzustellen, ob der Zustand ein Anfangszustand ist. In unserem Fall lassen wir mehrere Sut Instanzen zu, und unser Ausgangszustand ist ein leerer Zustand.
Nun ist es an der Zeit, unsere Commands zu definieren. Scalacheck bietet den Typ Command und die Untertypen SuccessCommand, UnitCommand und NoOp.
trait Command {
type Result
def run(sut: Sut): Result
def nextState(state: State): State
def preCondition(state: State): Boolean
def postCondition(state: State, result: Try[Result]): Prop
}
Beginnen wir mit der Clear. In diesem Fall ist es extends UnitCommand, da diese Operation kein Ergebnis erzeugt.
case object Clear extends UnitCommand {
override def run(sut: Sut): Unit = sut.clear()
override def nextState(state: State): State = state.copy(internal = Nil)
override def preCondition(state: State): Boolean = true
override def postCondition(state: State, success: Boolean): Prop = success
}
Wir sehen vier Methoden in diesem trait. run und nextState sind selbsterklärend. Andererseits sind preCondition und postConditions neue Konzepte in diesem Ansatz.
Wie zu Beginn des Beitrags erläutert, stellen wir mit den Vorbedingungen sicher, dass der aktuelle Befehl für den aktuellen Zustand sinnvoll ist. In diesem Fall erlauben wir immer Clear.
In den Nachbedingungen müssen wir überprüfen, ob das Ergebnis dem entspricht, was wir mit dem aktuellen Status erwarten. In diesem Fall ist Clear ein UnitCommand, so dass die API ein boolean übergibt, das angibt, ob die Befehlsausführung erfolgreich war oder nicht. Eine einfache Implementierung besteht darin, diese boolean zurückzugeben.
Der Befehl Push ist auch eine UnitCommand:
case class Push(v: Int) extends UnitCommand {
def run(sut: Sut): Unit = sut.push(v)
def nextState(state: State): State =
state.copy(internal = (v :: state.internal).take(state.capacity))
def preCondition(state: State): Boolean = true
def postCondition(state: State, success: Boolean): Prop = true
}
run ruft unser Sut.push(v) mit dem Wert case class Push auf und nextState setzt das Element an die erste Position der Liste und begrenzt die Kapazität.
Schließlich die Pop, die Command um den Typ Option[Int] erweitert:
case object Pop extends Command {
override type Result = Option[Int]
override def run(sut: SizedQueue[Int]): Result = sut.pop
override def nextState(state: State): State =
state.copy(internal = state.internal.drop(1))
override def preCondition(state: State): Boolean = true
override def postCondition(state: State, result: Try[Result]): Prop =
Success(state.internal.headOption) == result
}
- Die
runruft unsereSut.pop - Die
nextStateentfernt das erste Element - In der
postConditionsind wir erfolgreich, wenn das Ergebnis mit dem ersten Element übereinstimmt. Beachten Sie, dassstatehier der Zustand vor der Anwendung dernextState
Nun ist es an der Zeit, die letzte Commands Methode zu implementieren, die die Befehle erzeugen soll:
override def genCommand(state: State): Gen[Command] = {
val pushValueGen: Gen[Int] =
if (state.capacity > 0 && state.internal.size > state.capacity / 2) {
Gen.oneOf(
Gen.oneOf(state.internal),
Gen.chooseNum(0, 10)
)
} else Gen.chooseNum(0, 10)
Gen.frequency(
(3, Gen.const(Pop)),
(3, pushValueGen.map(Push)),
(1, Gen.const(Clear))
)
}
Im Allgemeinen besteht eine der größeren Herausforderungen bei der Entwicklung von eigenschaftsbasierten Tests darin, bei der Generierung der Eigenschaften nicht zu naiv zu sein. In diesem Fall haben wir einige Entscheidungen getroffen:
- Wenn die Warteschlange halb voll ist, haben wir eine 50%ige Chance, einen wiederholten Wert einzufügen. Im anderen Fall erzeugen wir einfach eine Zahl zwischen 0 und 10
- Wir wollen eine größere Häufigkeit von Pop- und Push-Operationen als Clear
Jetzt ist es an der Zeit, die Eigenschaftsprüfungen durchzuführen. Eine einfache Möglichkeit besteht darin, ein Properties Objekt als Suite zu erstellen:
object SizedQueueCommandsProperty extends Properties("Test sized queue") {
property("check") = SizedQueueCommands.property()
}
Starten Sie dann eine SBT Konsole und führen Sie unsere Eigenschaft aus:
testOnly com.xebia.functional.testing.SizedQueueCommandsProperty
[info] compiling 1 Scala source to /Users/fede/development/workspace/state-machine-testing/target/scala-2.13/test-classes ...
[info] + test sized queue.check: OK, passed 100 tests.
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 1 s, completed Feb 24, 2023, 7:59:02 PM
Stellen Sie sich nun vor, wir haben ein merkwürdiges Problem in unserer großen Warteschlange: Nachdem wir dasselbe Element dreimal gepusht haben, wird es nicht mit Pop aus der Warteschlange entfernt. Diese Darstellung mag verrückt klingen, aber Joe Norton beschrieb auf dem LambdaJam in Chicago, wie er QuickCheck verwendete , um Googles LevelDB zu testen und in einer ersten Iteration ein fehlgeschlagenes Gegenbeispiel von 17 Schritten zu finden.
Wir erhöhen die Mindestanzahl erfolgreicher Tests, um unsere Chancen zu erhöhen, und führen dann den Test durch:
[info] ! Test sized queue.check: Falsified after 1109 passed tests.
[info] > Labels of failing property:
[info] Initial state:
[info] TestState(5,List())
[info] Sequential Commands:
[info] 1. Push(0)
[info] 2. Push(0)
[info] 3. Push(0)
[info] 4. Pop => Some(0)
[info] 5. Pop => Some(0)
[info] 6. Pop => Some(0)
[info] 7. Pop => Some(0)
[info] > ARG_0: Actions(TestState(5,List()),List(Push(0), Push(0), Push(0), Pop, Pop, Pop, Pop),List())
[info] > ARG_0_ORIGINAL: Actions(TestState(5,List()),List(Push(0), Pop, Push(0), Push(0), Pop, Push(8), Push(1), Pop, Pop, Pop, Pop),List())
[info] Failed: Total 1, Failed 1, Errors 0, Passed 0
[error] Failed tests:
[error] com.xebia.functional.testing.SizedQueueCommandsProperty
[error] (Test / testOnly) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 1 s, completed Mar 1, 2023, 11:39:04 AM
Wie Sie aus der obigen Meldung ersehen können, fand Scalacheck das Problem nach "1109 bestandenen Tests" (dies war ein sehr merkwürdiger Wert) und verkleinerte die Argumente, um uns eine minimale Sequenz zu zeigen, die das Problem fand. Nach drei Push(0)s und drei Pops stellte der Test fest, dass die vierte Pop None hätte zurückgeben sollen, aber stattdessen Some(0) zurückgab.
Dies war nur ein Fall, aber diese Techniken sind in diesen Szenarien, in denen sich unsere Logik parallel zu einer Reihe von Befehlen entwickelt, extrem leistungsfähig. Stateful Testing bringt also zwei wesentliche Vorteile mit sich:
- Erkennen Sie Probleme, die nur auftreten, wenn sich unser System durch eine zeitliche Logik entwickelt, wie eine Reihe von Befehlen.
- Erweitern Sie das Wissen über Ihr System. Um diese Tests zu erstellen, müssen Sie über die Struktur und das Verhalten der zu testenden Systeme nachdenken und eine verhaltensorientierte Sichtweise entwickeln.
Auch andere Frameworks wie Hedgehog bieten diese Hilfsmittel für zustandsorientierte Tests. Wir werden die Übung mit dieser Bibliothek in einem späteren Beitrag wieder aufgreifen.
Bei Xebia verbessern wir mit spezialisierten Forschungsteams kontinuierlich unser Fachwissen im Bereich Testen und andere Software-Engineering-Techniken. Dieses Wissen geben wir an unsere Kunden weiter. Wenn Ihr Team das Verständnis und das Vertrauen in die Software, die es entwickelt, verbessern möchte, können Sie sich gerne an
Verfasst von
Fede Fernández
Unsere Ideen
Weitere Blogs
Contact




