Preis: 9,80 €
Erhältlich ab: Januar 2025
Umfang: 100
Das Open-Source-Tool Open Policy Agent dient der zentralen Verwaltung und Durchsetzung von Richtlinien in verteilten Systemen. Damit lassen sich Richtlinien als Code definieren und flexibel auf Anwendungsfälle wie API-Autorisierung, Cloud-Sicherheit und Kubernetes Governance anwenden. Durch die Entkopplung der Richtlinien- von der Anwendungslogik bietet das Tool eine skalierbare Lösung für Zugriffssteuerungen und Sicherheitsprüfungen.
In einer Zeit, in der immer mehr Aspekte der IT-Infrastruktur als Code behandelt werden, spielt auch die Verwaltung von Richtlinien eine entscheidende Rolle. Policy as Code (PaC) ist ein Ansatz, bei dem Richtlinien und Regeln in maschinenlesbarem Code festgehalten werden. Diese Richtlinien definieren Aspekte wie Sicherheit, Compliance und Governance in einem System oder der IT-Infrastruktur und werden ähnlich wie Software behandelt, d. h., sie können versioniert, getestet und automatisiert durch CI/CD Pipelines ausgerollt werden. Infrastructure as Code (IaC) und PaC haben viele Gemeinsamkeiten, da beide Ansätze das Ziel verfolgen, manuelle Prozesse durch automatisierbare, versionierbare und überprüfbare Konfigurationen zu ersetzen. Dieser Ansatz zur konsistenten und wiederholbaren Bereitstellung reduziert menschliche Fehler und erleichtert die Verwaltung komplexer Umgebungen [1].
Durch die Einführung von Policy as Code ergeben sich erhebliche Vorteile in der Verwaltung und Umsetzung von Sicherheits- und Governance-Richtlinien. Indem sie im Code definiert werden, können sie in Versionierungssystemen wie Git verwaltet, überprüft und bereitgestellt werden. Menschliche Fehler sowie manuelle Eingriffe, die bei der herkömmlichen Verwaltung von Richtlinien auftreten können, werden somit minimiert. Durch die Automatisierung der Richtlinien können Unternehmen schneller auf Änderungen der IT-Umgebung, auf Anforderungen oder sogar Sicherheitsbedrohungen reagieren [2].
Wichtig ist, dass Änderungen über alle Umgebungen hinweg konsistent und reproduzierbar sind. Auch wenn Ressourcen dynamisch angepasst oder skaliert werden, stellt PaC sicher, dass die Richtlinien automatisch auf jede neue Instanz übertragen und so Policy Drifts nahezu eliminiert werden. Solche Abweichungen führen in der Praxis häufig zu Sicherheitslücken oder anderen kritischen Betriebsproblemen. Die Versionierung des Codes ermöglicht zudem eine lückenlose Nachverfolgbarkeit der Änderungen, sodass Unternehmen sicherstellen können, dass jede Anpassung der Richtlinien dokumentiert und nachvollziehbar ist.
Durch die systematische und automatisierte Umsetzung von Sicherheitsrichtlinien wird das Risiko von Fehlkonfigurationen oder Sicherheitsverstößen stark reduziert. Die Definition der Richtlinien lässt sich direkt in den Entwicklungsprozess integrieren und ermöglicht eine proaktive Durchsetzung von Sicherheitsanforderungen [2]. Anstatt potenzielle Sicherheitslücken erst nachträglich zu beheben, können Entwickler von Anfang an mit sicheren, vordefinierten Richtlinien arbeiten. Dieser Ansatz unterstützt die Shift-Left-Strategie, bei der Sicherheitsaspekte bereits früh in den Entwicklungsprozess einbezogen werden [3].
Eine Policy ist eine verbindliche Regel oder Richtlinie, die klare Bedingungen und Vorgaben definiert, um den Zugriff auf Systeme, Daten oder Ressourcen zu steuern und zu verwalten. Sie legt fest, welche Aktionen unter welchen Voraussetzungen erlaubt oder untersagt sind und wie Abläufe in einem System gestaltet sein sollen [4]. Zur Durchsetzung von Policies wird eine sogenannte Policy Engine verwendet – eine Softwarekomponente oder ein System, das Richtlinien in einem bestimmten Kontext definiert, verwaltet und durchsetzt. Damit diese Anforderungen abgebildet werden können, besteht eine Policy Engine aus mehreren Komponenten, die jeweils unterschiedliche Aufgaben übernehmen. Policies können in verschiedenen Bereichen eingesetzt werden, um festzulegen, wer auf sensible Daten zugreifen darf, welche Netzwerkanfragen erlaubt oder blockiert werden und welche Benutzer auf bestimmte Cloud-Ressourcen zugreifen dürfen. Der Einsatz gewährleistet eine konsistente Einhaltung der festgelegten Regeln und stärkt somit die Sicherheit und Compliance in komplexen IT-Umgebungen [2], [5].
Es müssen geeignete Werkzeuge und Methoden bereitgestellt werden, mit denen sich Richtlinien flexibel definieren und verwalten lassen. Ein einfaches Beispiel für eine solche Regel könnte lauten: „Wenn User X eine Verbindung zu Ressource Y herstellen möchte, prüfe, ob er die erforderlichen Berechtigungen besitzt.“
Solche Regeln müssen jedoch nicht statisch sein; sie können auch als dynamische Regeln formuliert werden. Ein Beispiel für eine dynamische Regel wäre: „Mitarbeitende dürfen nur auf die Datenbank zugreifen, wenn sie sich im Firmennetzwerk befinden und der Zugriff während ihrer definierten Arbeitszeiten erfolgt.“
Die Engine prüft in diesem Schritt die definierten Regeln gegen den aktuellen Kontext. Sprich, jedes Mal, wenn eine Aktion oder Ereignis eintritt, das mit einer Policy versehen ist, muss die Policy Engine eine Entscheidung treffen, ob die Aktion erlaubt ist oder blockiert werden muss.
Nach der Entscheidungsfindung setzt die Engine diese um, indem sie erlaubte Aktionen ausführt und unerlaubte blockiert. Um sicherzustellen, dass sicherheitsrelevante Ereignisse unmittelbar bekannt sind, können auf Wunsch Benachrichtigungen an Administratoren oder spezifizierte Sicherheitsteams gesendet werden.
Manuelle Policies sind oft ineffizient, fehleranfällig und schwer skalierbar. Der Mangel an Automatisierung und Konsistenz erhöht das Sicherheitsrisiko und den Arbeitsaufwand. Eine Policy Engine ist daher ein vielseitiges Werkzeug, das es Organisationen ermöglicht, ihre Richtlinien und Regeln konsistent durchzusetzen und zu automatisieren. Ob im Bereich IT-Sicherheit, Netzwerkverwaltung, Cloud-Management oder Compliance – eine Policy Engine sorgt dafür, dass Prozesse effizient, sicher und regelkonform ablaufen. Somit können Unternehmen sicherstellen, dass alle Aktionen im System transparent, kontrollierbar und nachvollziehbar bleiben [2].
Nachdem wir uns mit dem Konzept von Policy as Code (Kasten: „Policies und ihre Durchsetzung“) und den grundlegenden Funktionen von Policy Engines auseinandergesetzt haben, stellt sich die Frage: Wie lässt sich eine flexible, skalierbare und anpassbare Lösung implementieren, die den vielfältigen Anforderungen moderner IT-Umgebungen gerecht wird? Hier kommt der Open Policy Agent (OPA) ins Spiel.
Der Open Policy Agent ist ein Open-Source-Tool, das eine leistungsstarke und flexible Lösung für die Verwaltung und Durchsetzung von Richtlinien in modernen IT-Umgebungen bietet. Es dient als zentrale Policy Engine, die in der Lage ist, in Echtzeit Entscheidungen über Zugriffsrechte und Regelkonformität zu treffen. Mit OPA lassen sich Richtlinien konsistent als Policy as Code definieren und in verschiedenen Systemen wie Kubernetes, Microservices, API Gateways oder CI/CD Pipelines anwenden [6].
Das Tool verwendet die deklarative Sprache Rego, um komplexe Regeln und Zugriffsbedingungen präzise und nachvollziehbar zu definieren. Diese Regeln werden als Code formuliert und direkt von OPA ausgewertet, um zu entscheiden, ob bestimmte Aktionen zulässig sind oder nicht. Häufig wird OPA in IT-Infrastrukturen integriert, um Richtlinien- und Zugriffsentscheidungen zu automatisieren, wodurch eine zentrale Verwaltung und flexible Anpassung der Regeln ermöglicht wird, ohne einzelne Systeme direkt anpassen zu müssen. Als Entscheidungsdienst liefert der Open Policy Agent auf Anfrage eine JSON-Antwort, die exakt festlegt, ob ein Benutzer Zugriff auf eine bestimmte Ressource erhält oder nicht.
Abb. 1: Entscheidungsfindung mit dem Open Policy Agent
Autorisierungsrichtlinien stellen eine besondere Form von Richtlinien dar und sind speziell darauf ausgelegt, die Entscheidungslogik für Zugriffe von der Kernlogik des jeweiligen Dienstes zu trennen. Ein Dienst – wie etwa ein Microservice, ein API Gateway oder eine andere Komponente – sendet hierfür eine Anfrage an OPA. Diese Anfrage wird als JSON-Dokument übermittelt und enthält alle notwendigen Informationen sowie den Kontext zur Auswertung der Richtlinie. Anschließend bewertet OPA die Anfrage basierend auf vordefinierten Policies, die in Rego geschrieben sind.
Diese Policies sind dafür konzipiert, verschiedene Bedingungen, wie Benutzerrollen, Ressourcenattribute etc. zu bewerten, und werden zusammen mit Daten gespeichert, die bei der Policy-Auswertung verwendet werden. Policies definieren die Regeln, die den Zugriff steuern, und die Daten liefern den zusätzlichen Kontext, der für die Entscheidungsfindung erforderlich ist. Zum Beispiel könnte die Policy definieren, wer auf bestimmte Ressourcen zugreifen darf, und die Daten könnten Informationen über Benutzer und Ressourcen enthalten. OPA gibt eine Entscheidung (in Form eines JSON) an den Service zurück. Diese Entscheidung kann einfach sein, wie z. B. „erlauben oder „verweigern“, oder detaillierte Informationen darüber enthalten, warum eine Entscheidung getroffen wurde [6] (Listing 1).
Listing 1: Beispiel Policy in Rego
package authz
import rego.v1
# Die Regel erlaubt den Zugriff nur, wenn der Benutzer die Rolle "admin" hat
# und der API-Endpoint "/admin/dashboard" aufgerufen wird.
default allow := false
allow if {
input.user.role == "admin"
input.request.path == "/admin/dashboard"
}
Aufgrund seiner Architektur wird OPA häufig zur Durchsetzung von Richtlinien in verteilten Systemen und Cloud-Umgebungen eingesetzt [7]:
Kubernetes Admission Control: Hier werden Richtlinien definiert, um sicherzustellen, dass bestimmte Vorgaben für den Cluster eingehalten werden, wie beispielsweise das Verbot von Root-Rechten für Container oder die Einschränkung auf zugelassene Images.
API-Zugriffssteuerung: Hier werden häufig Autorisierungsregeln definiert, die festlegen, wer auf bestimmte Endpunkte zugreifen darf oder welche Aktionen erlaubt sind.
CI/CD-Pipeline-Compliance: Hier wird dafür gesorgt, dass nur überprüfte oder genehmigte Änderungen bereitgestellt werden. Ist beispielsweise sichergestellt, dass alle Konfigurationen bestimmten Sicherheitsstandards entsprechen, kann eine Freigabe erteilt werden.
IAM: Hier kann im Zusammenspiel eine flexible, zentrale und feingranulare Verwaltung von Zugriffskontrollen ermöglicht werden.
Datenbanksicherheit: Hier können Zugriffe auf sensible Daten gesteuert werden. Beispielsweise können Regeln festlegen, wer Lese- und Schreibzugriff auf bestimmte Datensätze hat.
Netzwerkzugriffssteuerung: Hier können nicht nur in Cloud-Umgebungen, sondern auch in On-Prem-Netzwerken Regeln für Netzwerkzugriffe definiert und durchgesetzt werden. So kann beispielsweise der Zugriff auf bestimmte Ports und Dienste eingeschränkt werden.
IaC-Richtlinienprüfung: Hier kann OPA dazu genutzt werden, IaC-Templates zu überprüfen. Mit entsprechend definierten Policies kann sichergestellt werden, dass Infrastrukturelemente den Unternehmensrichtlinien entsprechen, bevor sie bereitgestellt werden.
Service Mesh Policy Enforcement: Hier kann ähnlich wie bei der Netzwerkzugriffssteuerung mit Richtlinien dafür gesorgt werden, dass nur bestimmte Services auf Netzwerkebene miteinander kommunizieren dürfen.
„Warum braucht man eigentlich zwei Systeme, um Regeln zu verwalten? Reicht nicht eines aus?“ Diese Frage stellt sich schnell, wenn man auf die Begriffe Policy Engine und Business Rule Engine stößt. Beide Systeme scheinen ähnliche Aufgaben zu übernehmen, doch ihr Fokus und ihre Anwendung unterscheiden sich grundlegend.
Eine Business Rule Engine (BRE) ist ein Softwaresystem oder eine Komponente, die dazu dient, Geschäftsregeln auf dynamische, automatisierte Weise zu verwalten und durchzusetzen. Geschäftsregeln sind vordefinierte Anweisungen oder Kriterien, die festlegen, wie ein Unternehmen in bestimmten Situationen Entscheidungen trifft oder Prozesse durchführt. Die BRE trennt diese Regeln von der zugrunde liegenden Anwendungslogik, was Flexibilität und Anpassungsfähigkeit ermöglicht, ohne dass Änderungen am Code vorgenommen werden müssen [8], [9].
Somit lässt sich sagen, dass beide Systeme Entscheidungsprozesse in Software- und IT-Systemen automatisieren und bestimmte Regeln oder Richtlinien durchsetzen. Doch wann wird nun welche Engine eingesetzt?
Policy Engines werden typischerweise in sicherheitskritischen oder complianceorientierten Bereichen eingesetzt, etwa zur Durchsetzung von Sicherheitsrichtlinien, zur Einhaltung von Datenschutzbestimmungen oder zur Verwaltung von Zugriffsrechten. Ebenso in IT-Sicherheitsinfrastrukturen wie Firewalls, Authentifizierungs- und Autorisierungssystemen. Die zentralisierten Richtlinien sind oft hoch abstrakt und beschreiben strategische Vorgaben. Sie regeln auf einer höheren Ebene, wie und wann bestimmte Maßnahmen durchzuführen sind, und können dabei helfen, gesetzliche oder interne Vorgaben einzuhalten [5]: „Nur User mit der Rolle ,Managerʻ oder ,Administratorʻ dürfen auf sensible Finanzdaten zugreifen.“
Business Rule Engines hingegen werden typischerweise in Anwendungen oder Systemen eingesetzt, bei denen dynamische Anpassungen der Geschäftslogik erforderlich sind. Das betrifft beispielsweise Finanzsysteme, Versicherungen oder Commerce-Anwendungen, in denen Geschäftsentscheidungen wie Preisgestaltung, Genehmigungsverfahren, Kreditvergabe oder Rabattberechnungen automatisiert werden. Es wird eine hohe Flexibilität und Agilität geboten, da Änderungen am Geschäftsprozess ohne Anpassung am Programmcode geschehen können. Dabei sind die Business Rules detailliert und spezifisch, mit einem klaren Fokus auf die Automatisierung von Geschäftsprozessen und Entscheidungen [9]: „Wenn der Bestellwert über 100 EUR liegt, gewähre einen 10-Prozent-Rabatt.“
Kriterium |
Policy Engine |
Business Rule Engine |
---|---|---|
Zweck |
Durchsetzung bzw. Automatisierung von Richtlinien und strategischen Vorgaben |
Automatisierung von Geschäftslogik und -entscheidungen |
Einsatzbereich |
IT-Sicherheit, Compliance, Governance |
Geschäftsprozesse, z. B. Preisgestaltung, Genehmigungen |
Abstraktionsebene |
höhere, strategische Ebene |
operativ, feingranulare Ebene |
Richtlinie vs. Regeln |
Richtlinien basieren auf strategischen Anforderungen |
Geschäftsregeln basieren auf operativen Anforderungen |
Flexibilität |
Änderungen erfordern oft formale Überprüfungen |
Regeln können oft direkt von Fachabteilungen geändert werden |
Tabelle 1: Steckbriefe Policy Engine und Business Rule Engine
Trotz ihrer Unterschiede lassen sich auch zahlreiche Gemeinsamkeiten feststellen. Beide Systeme zielen darauf ab, Entscheidungsprozesse basierend auf vordefinierten Regeln oder Richtlinien zu automatisieren und sollen in bestimmten Szenarien menschliche Entscheidungen ersetzen oder unterstützen. Dazu nutzen beide eine zentrale Sammlung von Regeln und Richtlinien, die explizit definiert sind und als Grundlage für das Systemverhalten dienen. Durch deren Änderung kann das Systemverhalten entsprechend angepasst und verändert werden. Diese Flexibilität sorgt dafür, dass auf neue Anforderungen reagiert werden kann, ohne den gesamten Anwendungscode zu verändern. Die Entscheidungslogik ist also von der eigentlichen Applikation oder dem System getrennt. Dadurch wird sichergestellt, dass die Regeln und Richtlinien unabhängig von der Anwendung gepflegt und gewartet werden können.
Gemeinsamkeit |
Policy Engine |
Business Rule Engine |
---|---|---|
Automatisierung von Entscheidungen |
Richtlinienentscheidungen |
Geschäftsentscheidungen |
regelbasierte Steuerung |
Richtlinien als Grundlage |
Geschäftsregeln als Grundlage |
Anpassungsfähigkeit |
Anpassung von Policies bei sich ändernden Anforderungen |
Anpassung von Regeln auf Grundlage neuer Geschäftsbedarfe |
Trennung von Logik und Ausführung |
Richtlinien sind vom Anwendungscode getrennt |
Geschäftsregeln sind vom Anwendungscode getrennt |
Ziele sind Konsistenz und Effizienz |
Sicherstellung konsistenter Richtlinienanwendung |
Sicherstellung konsistenter Geschäftsentscheidungen |
zentrale Verwaltung |
Policies |
Geschäftsregeln |
Tabelle 2: Gemeinsamkeiten Policy Engine und Business Rule Engine
Zusammengefasst lässt sich sagen, dass eine Policy Engine eher auf strategischer und sicherheitsorientierter Ebene agiert, während eine Business Rule Engine stärker auf die operative Umsetzung von Geschäftsprozessen fokussiert ist.
Der Open Policy Agent präsentiert sich als effektive Lösung für das Management von Richtlinien und Zugriffskontrollen in einer Vielzahl von Anwendungen und Systemen. Seine flexible Architektur und die Verwendung deklarativer Richtlinien ermöglichen eine präzise und granulare Steuerung von Zugriffsrechten, die den Anforderungen moderner Unternehmen entspricht. Das Tool kann sowohl als zentrale Komponente in Verbindung mit einem API Gateway in verteilten Umgebungen als auch für die detaillierte Steuerung einzelner Anwendungen eingesetzt werden.
Dabei müssen jedoch die damit verbundenen Herausforderungen angemessen berücksichtigt werden. Die Vielfalt der Anwendungen und Systeme sowie die dynamische Natur der Unternehmensumgebungen und denkbaren Einsatzmöglichkeiten stellen bedeutende Herausforderungen dar, die bei der Implementierung von OPA adressiert werden müssen.
In den kommenden Teilen dieser Serie werden wir tiefer in die Details einsteigen und uns mit dem produktiven Einsatz, der Testbarkeit, der Integration in CI/CD Pipelines sowie Szenarien in der Cloud und in verteilten Architekturen beschäftigen. Es bleibt also spannend!
[1] Ray, Jimmy: „Policy as Code“; O’Reilly Media, 2024
[2] „Introduction to policy as code with automation“: https://www.redhat.com/en/blog/policy-as-code-automation
[3] „Shift Left Security in Practice“: https://www.tigera.io/learn/guides/devsecops/shift-left-security/
[4] „Guide to Attribute Based Access Control (ABAC) Definition and Considerations“: https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-162.pdf
[5] „What Is a Policy Engine“: https://www.strongdm.com/what-is/policy-engine
[6] „Open Policy Agent“: https://www.openpolicyagent.org
[7] „Introducing Policy As Code – The Open Policy Agent (OPA)“: https://www.cncf.io/blog/2020/08/13/introducing-policy-as-code-the-open-policy-agent-opa/
[8] „Business Rule Engines (BRE)“: https://www.gartner.com/en/information-technology/glossary/bre-software
[9] „Business Rules Engine (BRE): What It is and How It Works?“: https://geekflare.com/business-rules-engine/
Scala gibt es schon seit 21 Jahren und es hat eine bewegte Geschichte hinter sich. Die Sprache ist aber nach wie vor eine hervorragende Wahl für funktionale Programmierung und Entwicklung auf der Java-Plattform. Die Unsicherheit um den Neustart der Sprache mit Scala 3 hat viele Entwickler:innen abgeschreckt. Für all diejenigen, die Scala in den letzten Jahren deswegen aus den Augen verloren haben, lohnt sich ein erneuter Blick: Der Übergang zu Scala 3 ist vollzogen und kommt mit ausgereiften Entwicklungswerkzeugen und einem umfangreichen und aufgeräumten Ökosystem.
Scala ist die Erfindung von Martin Odersky, Professor an der schweizerischen Hochschule EPFL in Lausanne, der das Projekt seit seiner Gründung leitet. Oderskys frühe Forschungsarbeiten kamen aus der funktionalen Programmierung, wo er insbesondere wesentliche Beiträge zu den dortigen leistungsfähigen Typsystemen leistete.
Wer Oderskys Arbeiten verfolgte, konnte Mitte der 1990er Jahre ahnen, was kommt: Odersky hatte sehr früh erkannt, dass die Java-Plattform die Programmiersprachenszene grundlegend verändern würde. Entsprechend begann er schon damals die Arbeit an einem eigenen Java-Compiler, um die Sprache um Features aus der funktionalen Programmierung zu erweitern, darunter Generics und Lambdaausdrücke. Die entstandene Sprache Pizza [1] hatte zwar nicht lange Bestand, sie war aber die Grundlage für die Integration von Generics in Java. Außerdem war Oderskys Compiler dem Sun-eigenen javac so überlegen, dass Sun kurzerhand den eigenen Compiler verwarf und durch Oderskys ersetzte.
Odersky war gleichzeitig klar, dass Java nicht die optimale Grundlage für die Unterstützung funktionaler Programmierung war. Schon bei der Integration von Generics waren die Nahtstellen deutlich sichtbar und sind es bis heute. (Vor allem bei der Unterscheidung zwischen primitiven Typen und Referenztypen.) So begann er 2001 die Arbeit an Scala, mit dem Ziel, in einer Sprache die Konzepte der objektorientierten mit denen der funktionalen Programmierung zu integrieren. Das erste Release kam 2003.
2006 kam Version 2.0 – keine Revolution, aber die Sprache wurde gründlich aufgeräumt und generalisiert, redundante Syntax wurde entfernt. Die unmittelbaren Folgeversionen der 2er-Reihe haben dann die Konstrukte der Sprache generalisiert und viele Einschränkungen schrittweise entfernt. Version 2.8 brachte im Jahr 2011 noch einmal einen massiven Featureschub, zu dem vor allem eine vollkommen neue Collections Library gehörte. Danach wurden die Änderungen der 2er-Versionen behutsamer.
2011 gab es bereits ein umfangreiches Ökosystem aus Libraries und Frameworks für Scala, darunter Akka [2], ein Actor-Framework, das auch von Java aus benutzt werden kann. Der Autor von Akka, Jonas Bonér gründete 2011 zusammen mit Odersky und Paul Philips die Firma Typesafe, die ab da auch die Entwicklung des Scala-Compilers maßgeblich übernahm. Typesafe wurde 2016 in Lightbend und schließlich dieses Jahr in Akka umbenannt – der Fokus hat sich inzwischen von Scala weg und auf reaktive Programmierung verschoben. Ebenfalls 2016 gründeten Heather Miller und Martin Odersky das Scala Center – eine Stiftung, um die Weiterentwicklung von Scala als Open-Source-Projekt zu sichern.
Odersky arbeitete parallel an einer neuen theoretischen Grundlage für Scalas Typsystem, dem „Dependent Object Types Calculus“ (DOT) [3] sowie an einem neuen Compiler für die Scala-ähnliche Sprache „Dotty“, die 2020 schließlich in „Scala 3“ umbenannt wurde. Scala 2 existiert weiter, ist seitdem aber im Wartungsmodus. Es gibt zwar immer noch Scala-2-Projekte und auch einige prominente Frameworks (insbesondere das Big-Data-Projekt Spark), diese sind aber noch nicht bei Scala 3 angekommen. Die meisten etablierten Scala-Libraries haben aber Scala-3-Versionen – der neuen Version gehört also die Zukunft. Wenn wir in dieser Artikelreihe von „Scala“ sprechen, meinen wir stets Scala 3.
Vom Start weg war Scala die bessere Sprache als Java. Funktionale Programmierer:innen waren ohnehin glücklich über die Unterstützung ihres Paradigmas. Aber auch wer an den funktionalen Konzepten von Scala kein Interesse hatte, profitierte beispielsweise von der kompakteren Syntax, lokalen Typinferenz und den vereinheitlichten Generics.
Aber zu einer Sprache gehören auch Tooling und Ökosystem, und da gab es lange Zeit ein paar Mankos: Der Compiler war vergleichsweise langsam und die IDE-Unterstützung nicht so ausgereift wie für Java.
Ein größeres Problem mit Scala 2 war, dass Versionssprünge innerhalb der Major-Version (also zum Beispiel von 2.11 zu 2.12) generell mit Änderungen der ABI, also den Konventionen der generierten Bytecodes, einhergingen: Libraries, die mit Scala 2.11 kompiliert waren, konnten also nicht in Scala-2.12-Projekten verwendet werden. Für ein Release einer Library genügte es also nicht, dass die Autor:innen ein einzelnes JAR-File bereitstellen – sie mussten das auch für jede unterstützte Version des Scala-Compilers tun. Wer ein Projekt auf eine neuere Scala-Version aktualisieren wollte, brauchte also entsprechende Versionen aller Libraries. Wenn eine fehlte – Pech gehabt.
Inzwischen hat sich die Situation aber stabilisiert: Generell können alle Scala-3-Libraries und Scala-2.13-Libraries in Projekten mit der aktuellen Scala-3-Version benutzt werden. Die Situation dürfte so erfreulich bleiben, weil die seit 2022 etablierte, formalisierte Governance-Struktur für das Scala-Projekt [4] unter anderem vorhersehbare Releasezyklen und Versionskompatibilität regelt.
Ein weiteres Sorgenkind war das Scala-Build-Werkzeug sbt, Scalas Pendant zu Maven oder Gradle. Die Abkürzung steht eigentlich für „simple build tool“, die idiosynkratische Projektbeschreibungssprache machte aber vielen Benutzer:innen Kopfschmerzen. Auch hier hat sich die Situation verbessert, das Tool ist durch eine aufgeräumte Sprache, bessere Fehlermeldungen und Dokumentation deutlich zugänglicher.
Der Übergang zu Scala 3 brachte auch einen deutlich schnelleren Compiler mit sich und die Performance des Scala-2-Compilers zeigt sich gegenüber der Anfangszeit ebenfalls deutlich verbessert. Auch die IDE-Unterstützung ist angekommen, mit dem Scala-LSP-Projekte Metals [5] und dem von JetBrains selbst entwickelten Scala-Plug-in für IntelliJ IDEA.
Für Entwickler:innen ist auch das Ökosystem von Libraries und Frameworks wichtig: Natürlich stehen einerseits die Libraries der Java-Welt zur Verfügung. Spezielle Scala-Libraries machen aber noch mehr der sprachspezifischen Vorteile verfügbar. Hier gab es in der Scala-Community lange Zeit eine unübersichtliche Situation – viele Libraries waren schwer zu benutzen, weil sie statt auf verständliche Dokumentation und Benutzbarkeit lieber auf besonders clevere Benutzung von fortgeschrittenen Scala-Features setzten. Grabenkämpfe (zum Beispiel um das scalaz-Projekt) und eine toxische Haltung gegenüber Neuankömmlingen taten ihr Übriges.
Auch hier stellt sich die aktuelle Situation deutlich besser da. Vor allem die Typelevel-Gruppe [6] stellt eine Sammlung kuratierter, aufgeräumter und aufeinander abgestimmter Libraries zu Verfügung, die Scala für die Entwicklung großer Anwendungen fit machen.
Scala wird vielerorten als das „bessere Java“ beworben und tatsächlich kann Scala alles, was Java kann und erlaubt oft kürzere und elegantere Programme als in Java. Wirklich effektive Scala-Programmierung baut hauptsächlich auf funktionaler Programmierung auf, die in den großen Scala-Codebasen beispielsweise bei Netflix oder Disney+ das OO-Paradigma weitestgehend abgelöst hat. Entsprechend beleuchten wir in dieser Artikelreihe besonders die funktionale Programmierung.
[1] Odersky, Martin; Wadler, Philip: „Pizza into Java: Translating theory into practice“; in: 24th ACM Symposium on Principles of Programming Languages, 1997
[2] Akka: https://github.com/akka/akka
[3] Amin, Nada; Adriaan Moors, Odersky, Martin: „Dependent Object Types“; in: 19th International Workshop on Foundations of Object-Oriented Languages, 2012
[4] https://www.scala-lang.org/governance/
Vielleicht kennen Sie das: Sie bemühen sich um eine hohe Testabdeckung, investieren Zeit und Mühe, um mit Ihren Testdaten auch Randfälle zu berücksichtigen und doch durchläuft eines Tages ein vermeintliches Refactoring alle Tests, nur um in Produktion an einer obskuren Datenkonstellation zu scheitern, die bis dato niemand auf dem Schirm hatte. In solchen und ähnlichen Fällen kann Property-based Testing helfen.
Einzelfalltests sind nicht sonderlich effektiv im Aufspüren von Fehlern. Warum? Mit ihnen lassen sich – per definitionem – keine unvorhergesehenen Datenkonstellationen testen, Missverständnisse und Fehlannahmen werden in handgefertigten Tests oft repliziert und gerade das Zusammenspiel verschiedener Funktionalitäten ist selten ausreichend abgedeckt. Wer will schon alle relevanten Testfälle auf allen möglichen Featurekombinationen ausprogrammieren?
Property-based Testing geht einen anderen Weg; das Prinzip ist bestechend einfach: Statt Tests selbst zu schreiben, lassen wir sie vom Testframework generieren. Wir formulieren keine Erwartungen für konkrete Testdaten, sondern allgemeingültige Eigenschaften des Systems, das heißt Beobachtungen, die für alle Eingaben gelten. Das Framework erzeugt mit Hilfe von Testfallgeneratoren eine große Zahl zufälliger Datenkonstellationen und prüft sie hinsichtlich der formulierten Eigenschaften. Wird ein Gegenbeispiel gefunden, wird es systematisch vereinfacht – wir sprechen von Shrinking –, um ein minimales Gegenbeispiel zu finden und dem/der Entwickler:in schließlich zu präsentieren.
Wir sehen: Auch beim Property-based Testing erhalten wir keinen formalen Beweis für die Korrektheit unseres Systems; das Testframework ist ein Falsifizierer, kein Verifizierer. Durch die Zufälligkeit und die große Zahl getesteter Fälle ist die Aussagekraft erfolgreicher Tests aber in aller Regel sehr viel höher als bei selbst geschriebenen Testfällen. Außerdem helfen die Gegenbeispiele bei der Fehlersuche: Wiederholte Testläufe geben uns wertvolle Hinweise auf Fehlerursachen und mit angepassten Generatoren können wir Vermutungen bestätigen oder widerlegen. Oft lassen sich Fehler auf diese Weise stark eingrenzen, ohne auch nur einmal in den Code zu schauen. Die eigentliche Stärke von Property-Tests liegt jedoch an anderer Stelle: Da wir nicht wissen können, mit welchen Datenkonstellationen wir es zu tun haben, müssen wir anders über unser System nachdenken als beispielsweise in Unit-Tests. Dieser andere Blick führt weg von Implementierungsdetails und rückt Fragen der Korrektheit in den Fokus. Das hilft, Missverständnisse und Fehlkonzeptionen frühzeitig aufzudecken und das System insgesamt besser zu verstehen.
Das Aufspüren guter Eigenschaften bedarf einiger Übung. Bevor wir uns damit befassen, was „gute“ Eigenschaften ausmacht und wie wir sie finden, wollen wir uns ansehen, wie sich Property-Tests in Scala umsetzen lassen. Dafür gibt es mittlerweile eine ganze Reihe von Bibliotheken. Wir haben uns für ScalaCheck [1] entschieden. Die Bibliothek ist weit verbreitet und bietet eine gute Integration mit anderen Testframeworks wie ScalaTest [2] und Spec2 [3].
Um ScalaCheck verwenden zu können, müssen wir die Bibliothek als (Test-)Abhängigkeit aufnehmen; beim Einsatz von sbt zum Beispiel mit:
libraryDependencies +=
"org.scalacheck" %% "scalacheck" % "1.18.1" % Test
Damit können wir unsere ersten Property-Testing-Schritte in einer sbt-Konsole unternehmen, um uns mit dem Vorgehen vertraut zu machen. Als einfache Fingerübung wollen wir das Kommutativgesetz für die Integeraddition als Eigenschaft formulieren:
❯ sbt "Test / console"
scala> import org.scalacheck.Prop.forAll
scala> val propCommutativeAdd = forAll { (a: Int, b: Int) => a + b == b + a }
scala> propCommutativeAdd.check()
+ OK, passed 100 tests.
Mit der Methode forAll aus org.scalacheck.Prop können wir aus einfachen Prädikaten Objekte vom Typ Prop erzeugen. Sie besitzen eine check-Methode, mit der sich die Tests anstoßen lassen. In der letzten Zeile meldet uns das Framework, dass es unsere Eigenschaft propCommutativeAdd auf 100 Testfällen überprüft und kein Gegenbeispiel gefunden hat.
Wir können die Testausführung beeinflussen, indem wir check ein Objekt vom Typ Test.Parameters übergeben. So könnten wir beispielsweise die Anzahl der Testdurchläufe auf 10 000 erhöhen:
scala> import org.scalacheck.Test.Parameters
scala> propCommutativeAdd.check(
| Parameters.default.withMinSuccessfulTests(10000))
+ OK, passed 10000 tests.
Was passiert, wenn wir eine falsche Eigenschaft aufstellen? Das zeigt uns Listing 1.
Listing 1
scala> forAll { (a: Int, b: Int) => a + b == 0 }.check()
! Falsified after 0 passed tests.
> ARG_0: 0
> ARG_1: 1
> ARG_0_ORIGINAL: 258742235
> ARG_1_ORIGINAL: 102010326
Wenig überraschend wird bereits im ersten Durchlauf ein Gegenbeispiel gefunden: Für a = 258742235 und b = 102010326 gilt unsere Behauptung nicht. Neben den ursprünglichen Werten, die den Fehler provoziert haben, sehen wir weitere Eingaben, für die der Test fehlschlägt: Auch für a = 0 und b = 1 gilt unsere Eigenschaft nicht. Diese kleinsten Werte sind das Ergebnis des oben erwähnten Shrinking.
Warum aber sehen wir in der Ausgabe oben nur die verwendeten Eingabedaten, nicht aber die erwarteten und tatsächlichen (Ergebnis-)Werte, die den Test fehlschlagen lassen? Die dürftige Ausgabe ist nicht ScalaCheck anzulasten, sondern unserer Eigenschaft: Wir können zwar jede Funktion, die ein boolesches Ergebnis liefert, als Eigenschaft verwenden, der Testrunner kann uns hier aber keine weitere Diagnostik bieten. Verwenden wir statt == die Operationen =? oder ?= aus Prop für den Abgleich, erhalten wir eine sinnvolle Fehlermeldung, wenn die verglichenen Werte voneinander abweichen – die beiden Operationen unterscheiden sich nur darin, welcher der Vergleichswerte als Erwartung und welcher als Ergebnis gewertet wird:
scala> forAll { (a: Int, b: Int) => a + b ?= 0 }.check()
! Falsified after 0 passed tests.
> Labels of failing property:
Expected 0 but got 1
...
Wir können natürlich Eigenschaften formulieren, die über einfache Wertvergleiche hinausgehen: Prop bietet Methoden, um andersartige Eigenschaften, wie das Auftreten einer Ausnahme, auszudrücken, und Kombinatoren, um Eigenschaften einzuschränken und miteinander zu verknüpfen. Alles, was die klassische Logik hergibt, können wir ausdrücken. Wir beschränken uns hier auf die Elemente, die uns in unseren Beispielen begegnen werden: Mit && und || lassen sich Und- und Oder-Verknüpfungen realisieren, mit ==> können wir bedingte Eigenschaften formulieren – sie werden nur für Testfälle geprüft, die die Vorbedingung erfüllen – und mit ++ verketten. Um bei sehr komplexen Eigenschaften nicht den Überblick zu verlieren, können wir mit |: Teilausdrücke mit Textlabels versehen, die im Fehlerfall ausgegeben werden. ScalaCheck erlaubt außerdem das Klassifizieren von Tests mit classify und collect, um an Informationen zur Testfallverteilung zu kommen.
Mit org.scalacheck.Properties können wir Eigenschaften in Testsuiten bündeln – sie lassen sich mit sbt test ausführen, bieten aber auch eine check-Methode, mit der wir sie von der Konsole aus starten können (Listing 2).
Listing 2
import org.scalacheck.Prop._
import org.scalacheck.Properties
object IntSpec extends Properties("integer operations") {
property("commutative add") =
forAll { (a: Int, b: Int) => a + b ?= b + a }
}
ScalaCheck bietet Generatoren (und Shrinker) für primitive Typen und übliche Container wie Tupel, Listen, Sets und so weiter. Das ist ausreichend für Spielereien wie propCommutativAdd; in aller Regel wollen wir aber eigene Funktionen testen und werden dafür eher früher als später Generatoren für eigene Typen schreiben müssen beziehungsweise mit eingeschränkten Wertebereichen arbeiten wollen. Als Themenkomplex bieten Generierung, Reduktion und nicht zuletzt die Verteilung von Testdaten genug Stoff für einen eigenen Artikel. Wir begnügen uns an dieser Stelle mit einem eher flüchtigen Blick in die Werkzeugkiste.
Generatoren können wir auf Basis von org.scalacheck.Gen und den dort enthaltenen Methoden erzeugen. Von relativ einfachen Generatoren wie const und choose, mit denen wir konstante Werte beziehungsweise wertemäßig eingeschränkte Integer erzeugen können, über Generatoren wie listOf, either und stringOf, die zur Erzeugung von Containern mit vorgegebenen Elementtypen dienen, bis hin zu Kombinatoren wie someOf, oneOf und frequency, die es erlauben, eine zufällige Auswahl aus einer Reihe von Generatoren zu verwenden: Das Spektrum an Werkzeugen zur Definition eigener, maßgeschneiderter Generatoren lässt kaum Wünsche offen. Darüber hinaus können wir Scalas for Comprehension verwenden, um auch komplexe, strukturierte Daten zu generieren. Um eigene Generatoren in Tests zu verwenden, können wir sie entweder direkt an forAll übergeben oder als implizite Arbitrary-Instanzen zur Verfügung stellen.
Sehen wir uns dazu ein Beispiel an. Angenommen, wir wollten die Implementierung von binären Suchbäumen testen (Listing 3).
Listing 3
case class Value(value: String)
enum BinarySearchTree {
case Leaf()
case Branch(
left: BinarySearchTree,
key: Int,
value: Value,
right: BinarySearchTree)
def find(searchKey: Int): Option = ???
def insert(newKey: Int, newValue: Value): BinarySearchTree = ???
def delete(delKey: Int): BinarySearchTree = ???
def toList: List[(Int, Value)] = ???
def keys: List[Int] = ???
}
Für die Tests werden wir beliebige Bäume erzeugen müssen, aber auch Schlüssel-Wert-Paare, um die verschiedenen Schnittstellenmethoden zu prüfen. Die Generierung von Werten wollen wir etwas genauer untersuchen.
Auch wenn uns die Knoten-Payload in unseren Tests nicht weiter kümmern wird, beschränken wir uns auf nichtleere alphanumerische Strings – vor allem der Lesbarkeit von Fehlermeldungen wegen. Ein Generator, der uns solche Value-Objekte liefert, ist:
val genValue: Gen[Value] =
Gen.nonEmptyStringOf(Gen.alphaNumChar).map(Value(_))
given arbValue: Arbitrary[Value] = Arbitrary(genValue)
Im Grunde genügt genValue, um Eigenschaften für beliebige Values zu formulieren. Durch die als given definierte Arbitrary[Value]-Instanz müssen wir den Generator aber nicht explizit angeben: forAll sucht in allen mit given zur Verfügung gestellten Arbitrary-Instanzen nach etwas Passendem.
Schauen wir uns einmal an, was passiert, wenn wir eine Eigenschaft prüfen, die niemals gilt:
scala> import BinarySearchTreeSpec._, BinarySearchTreeSpec.given
scala> import org.scalacheck.Prop.forAll, import org.scalacheck.Gen
scala> forAll(Gen.resize(20, genValue)) { (v: Value) => false }.check()
! Falsified after 0 passed tests.
> ARG_0: Value(Hi6oVyZmp9bcIwe)
Schon der erste Testfall liefert, wie erwartet, ein Gegenbeispiel. Gen.resize verwenden wir hier nur, um sicherzustellen, dass bereits dieser erste Fall eine gewisse Länge aufweist, denn so sehen wir eindeutig: Eine Testfallreduktion findet nicht statt. Unsere Eigenschaft schlägt ja für jedes Value fehl, es gäbe also kürzere Gegenbeispiele. Allein, ScalaCheck weiß nicht, wie es diese erzeugen soll, da wir zwar einen Generator, aber keinen Shrinker definiert haben.
Einen Shrinker selbst zu implementieren, ist nicht ohne, doch wir haben Glück: Als einfacher Wrapper ist unser Datentyp isomorph zu Strings. Um einen String in ein Value und wieder zurück zu übersetzen, müssen wir den Wert nur ein- beziehungsweise auspacken. Diese beiden Transformationen sind schon alles, was die Methode xmap aus org.scalacheck.Shrink benötigt, um aus einem String-Shrinker, den ScalaCheck mitbringt, einen Value-Shrinker zu machen:
given shrinkValue: Shrink[Value] =
Shrink.xmap(Value(_), { case Value(s) => s })
Wiederholen wir unser Experiment, sehen wir nun tatsächlich die Testfallminimierung am Werk:
scala> forAll(Gen.resize(20, genValue)) { (v: Value) => false }.check()
! Falsified after 0 passed tests.
> ARG_0: Value()
> ARG_0_ORIGINAL: Value(BDUy6jvs6E)
Ganz zufriedenstellend ist das Ergebnis aber noch nicht. Mit unserem Generator haben wir nichtleere, alphanumerische Strings erzeugt. Der Shrinker respektiert diese Einschränkung offensichtlich nicht. Hier müssen wir nachbessern, beispielweise mit einem suchThat-Zusatz, der die zulässigen Vereinfachungen anhand eines Prädikats einschränkt (Listing 4).
Listing 4
def isValid(v: Value): Boolean =
v match {
case Value(s) => s.nonEmpty && s.forall(_.isLetterOrDigit)
}
given shrinkValue: Shrink[Value] =
Shrink.xmap(Value(_), { case Value(s) => s }) suchThat isValid
Da auch Gen eine suchThat-Methode bietet, mag es verlockend erscheinen, unser neues isValid-Prädikat auch für den Generator zu verwenden, um beide Seiten der Value-Generierung in Deckung zu bringen. Hier ist allerdings Vorsicht geboten, wie das folgende Experiment verdeutlicht:
scala> import org.scalacheck.Arbitrary.arbitrary
scala> def gen = arbitrary[String].map(Value(_)) suchThat isValid
scala> forAll(gen) { (v: Value) => true }.check()
! Gave up after only 14 passed tests. 501 tests were discarded.
Beim Einsatz von suchThat werden viele der zufällig generierten Testfälle einfach verworfen – zu viele. Als Faustregel können wir uns merken, dass spezifische Generatoren, die passende Fälle generieren, besser sind als solche, die viele der generierten Fälle verwerfen.
Wir wissen nun, wie wir Eigenschaften formulieren, doch wo nehmen wir sie her? John Hughes, einer der Autoren von QuickCheck, der Haskell-Bibliothek, der wir die Idee von Property-based Testing verdanken, hat einige Strategien zusammengetragen, die uns dabei helfen, gute Eigenschaften zu identifizieren. Bevor wir uns einige dieser Strategien ansehen, müssen wir aber klären, was das überhaupt ist: eine gute Eigenschaft.
Eine Eigenschaft ist gut, wenn sie uns beim Aufspüren von Fehlern hilft, wenn sie eher fachliche als technische Gegebenheiten betrifft. Wir erinnern uns: In der Einleitung hatten wir die relative Ferne technischer Details als eine der Stärken von Property-Tests hervorgehoben. Besonders eine Falle gilt es zu vermeiden: Da wir über die Testdaten wenig bis gar nichts wissen, müssen wir die Eigenschaften irgendwie auf generische Weise formulieren, dabei kann es passieren, dass wir unsere Implementierung im Test mehr oder weniger nachbauen. Das kostet viel Mühe und generiert wenig Wert, denn oftmals replizieren wir dabei etwaige Missverständnisse und/oder Fehler. Wann immer unser Testcode der eigentlichen Implementierung sehr nahe kommt, sollten unsere Alarmglocken schrillen.
Nehmen wir als Beispiel das „Hello World“ der Softwareverifikation: die reverse-Funktion auf Listen. Ein konkreter Testfall dazu könnte beispielsweise so aussehen:
scala> assert(List(5, 1, 7, 0).reverse == List(0, 7, 1, 5))
Wie formulieren wir den hinter dieser Gleichung stehenden Zusammenhang nun als Eigenschaft von reverse? Indem wir das Ergebnis von l.reverse für eine beliebige Liste l irgendwie voraussagen? Der Code dafür wäre sicher nicht weniger kompliziert als reverse selbst. Würde es uns tatsächlich gelingen, auf einfache, verlässlich korrekte Weise eine Voraussage zu treffen, müssten wir uns fragen: Warum ist der Testcode und nicht unsere echte reverse-Implementierung? So kommen wir nicht weiter. Was also tun?
Wir müssen umdenken! Es gibt eine sinnvolle Eigenschaft von reverse, die wir tatsächlich sehr einfach testen können: Zweimaliges Anwenden der Funktion liefert die ursprüngliche Liste. Machen wir ein kleines Experiment (Listing 5).
Listing 5
scala> def revProp(rev: List[Int] => List[Int]) =
| forAll { (l: List[Int]) => rev(rev(l)) ?= l }
scala> revProp(_.reverse).check()
+ OK, passed 100 tests.
scala> revProp({ l => l }).check() // dumbReverse
+ OK, passed 100 tests.
Unsere Idee funktioniert in Bezug auf reverse einwandfrei, doch wir können eine offensichtlich falsche Implementierung (dumbReverse) einsetzen, für die unser Property-Test ebenfalls durchläuft. Mit dieser Eigenschaft allein ist reverse also nur unvollständig charakterisiert. Allerdings lässt sich leicht eine weitere Eigenschaft finden, die zwischen reverse und dumbReverse unterscheiden kann (Listing 6).
Listing 6
scala> def dumbProp(rev: List[Int] => List[Int]) =
| forAll { (l: List[Int]) => rev(l) == l }
scala> dumbProp({ l => l }).check() // dumbReverse
+ OK, passed 100 tests.
scala> dumbProp(_.reverse).check()
! Falsified after 5 passed tests.
> ARG_0: List("0", "1")
> ARG_0_ORIGINAL: List("1", "776735732")
Tatsächlich gilt dumbProp für dumbReverse, nicht aber für reverse. Der Shrinker hilft uns, Fälle zu identifizieren, für die das Verhalten beider Funktionen divergiert: zum Beispiel alle mindestens zweielementigen Listen mit paarweise disjunkten Elementen. Hieraus eine Eigenschaft für reverse zu formulieren, die für dumbReverse nicht gilt, überlassen wir allen interessierten Leser:innen zur Übung und wenden uns wieder unseren Suchbäumen zu, um an ihnen einige Strategien für die Suche nach guten Eigenschaften vorzustellen.
Alle Arten von Invarianten liefern sehr nützliche Eigenschaften. Für alle binären Suchbäume etwa gilt das, was Listing 7 zeigt.
Listing 7
def isValid(tree: BinarySearchTree): Boolean =
tree match {
case Leaf() => true
case Branch(left, key, _, right) =>
left.keys.forall(_ < key) && isValid(left) &&
right.keys.forall(_ > key) && isValid(right)
}
Alle Operationen, die Suchbäume erzeugen, müssen diesem Kriterium genügen. Daraus ergeben sich unmittelbar die Eigenschaften aus Listing 8.
Listing 8
property("empty valid") = isValid(Leaf())
property("insert valid") =
forAll {(t: BinarySearchTree, k: Int, v: Value) =>
isValid(t.insert(k, v))}
property("delete valid") =
forAll {(t: BinarySearchTree, k: Int) =>
isValid(t.delete(k))}
Bauen wir absichtlich einen Fehler in insert ein, passiert etwas auf den ersten Blick Unerwartetes: Auch die delete-Eigenschaft schlägt fehl, obwohl die Funktion nichts mit insert zu tun hat. Des Rätsels Lösung liegt im Testbaumgenerator, den wir bislang unterschlagen haben:
def makeBST[V](nodes: List[(Int, Value)]): BinarySearchTree =
nodes.foldLeft(Leaf()) { case (t, (k,v)) => t.insert(k, v) }
val genBST: Gen[BinarySearchTree] =
Gen.listOf(arbitrary[(Int, Value)]).map(makeBST)
given arbBST: Arbitrary[BinarySearchTree] = Arbitrary(genBST)
Wir verwenden insert bei der Generierung unserer Testfälle! Ein Fehler in dieser Operation führt also dazu, dass wir ungültige Testbäume erzeugen. Hier haben wir eine entscheidende Lücke in unseren Tests aufgedeckt: Wir müssen sicherstellen, dass wir nur auf gültigen Suchbäumen testen. Auch unser Shrinker sollte die Invariante zumindest erhalten:
property("arbitrary valid") = forAll {(t: BinarySearchTree) => isValid(t)}
property("shrink valid") = forAll {(t: BinarySearchTree) =>
isValid(t) ==> shrinkBST.shrink(t).forall(isValid)}
Das sollten wir uns unbedingt merken: Bei Invarianten müssen wir immer auch Generatoren und Shrinker berücksichtigen. Andernfalls stehen wir vor dem klassischen „Garbage in, garbage out“-Problem und unsere Tests verlieren jede Aussagekraft.
Die nächste Frage, die uns auf die Spur sinnvoller Eigenschaften bringt, ist die nach den Nachbedingungen (Postconditions) der einzelnen Operationen.
Ein besonders interessantes Beispiel liefert find: Was erwarten wir nach einem Aufruf von find? Nun, die Operation ändert den Baum nicht, insofern gilt unser Interesse dem Ergebnis. Ist der Suchschlüssel im Baum enthalten, erwarten wir den zu diesem Schlüssel gehörenden Wert als Rückgabe. Existiert der Suchschlüssel nicht, erwarten wir None als Ergebnis. Können wir vorhersagen, ob ein beliebiger Suchschlüssel in einem beliebigen Baum vorkommt und falls ja, welcher Wert für ihn hinterlegt ist? Die einzige Möglichkeit, die wir für eine solche Vorhersage hätten, ist find selbst. Eine Eigenschaft von find über das Ergebnis von find zu charakterisieren ist aber offensichtlicher Unsinn. Wir müssen die Sache anders angehen: Können wir die Antwort auf die Frage „Existiert der Suchschlüssel und falls ja, was ist der zugehörige Wert?“ irgendwie garantieren, statt sie zu ermitteln? Ja! Fügen wir einen Schlüssel zum Baum hinzu, ist er sicher im Baum vorhanden und trägt den von uns eingefügten Wert. Löschen wir den Schlüssel, existiert er sicher nicht. Am Ende ergeben sich daraus zwei separate Eigenschaften für find (Listing 9).
Listing 9
property("find post present") =
forAll {(k: Int, v: Value, t: BinarySearchTree) =>
t.insert(k, v).find(k) ?= Some(v) }
property("find post absent") =
forAll {(k: Int, v: Value, t: BinarySearchTree) =>
t.delete(k).find(k) ?= None }
In Unit-Tests bekämen wir vielleicht Bauchschmerzen, wenn wir eine Methode anhand anderer, ebenfalls unter Test stehender Methoden prüfen würden. Hier zeigt sich ein grundlegender Unterschied: Property-based Testing folgt einem holistischen Ansatz. Als isolierte Funktion ist find weit weniger interessant als im Zusammenspiel mit anderen. Uns interessieren Suchbäume als Ganzes, nicht zerlegt in ihre Einzelteile.
Das Zusammenspiel von Funktionen bringt uns zu einem anderen Ansatz, der einen reichen Fundus an interessanten Eigenschaften generiert: sogenannte metamorphe Eigenschaften.
Zuweilen ist es schwer, das Ergebnis einer Operation vorherzusagen. Oft ist es einfacher, darüber nachzudenken, wie sich eine Veränderung der Eingabe auf das Ergebnis auswirkt. Nehmen wir insert: Was passiert, wenn wir vor dem Aufruf ein anderes Schlüssel-Wert-Paar einfügen?
Dabei müssen wir sicherlich zwei Fälle unterscheiden: In einigen Testdurchläufen werden wir den gleichen Schlüssel zweimal einfügen, in anderen werden sich die beiden Schlüssel unterscheiden. Fügen wir zweimal den gleichen Schlüssel ein, wird der zweite insert-Aufruf den ersten überschreiben. Es ist, als hätte die erste Operation nie stattgefunden.
Verwenden wir zwei verschiedene Schlüssel, ist das Ergebnis schwieriger vorherzusagen, hier machen wir uns das Leben einfach: Wir stellen eine erste Hypothese auf – die Reihenfolge der Aufrufe ist egal, schließlich landen beide Paare in unserem Baum – und überlassen ScalaCheck die Arbeit (Listing 10).
Listing 10
scala> forAll {(k1: Int, k2: Int, v1: Value, v2: Value,
| t: BinarySearchTree) =>
| (k1 == k2 ==> ("insert same key twice" |:
| (t.insert(k1, v1).insert(k2, v2) ?=
| t.insert(k2, v2)))) ++
| (k1 != k2 ==> ("insert different keys" |:
| (t.insert(k1, v1).insert(k2, v2) ?=
| t.insert(k2, v2).insert(k1, v1))))
| }.check()
! Falsified after 0 passed tests.
> Labels of failing property:
Expected Branch(Branch(Leaf(),0,Value(U),Leaf()),1,Value(Z),Leaf())
but got Branch(Leaf(),0,Value(U),Branch(Leaf(),1,Value(Z),Leaf()))
insert different keys
...
An dem Label, das uns ScalaCheck ausgibt (insert different keys), erkennen wir sofort, dass unsere Hypothese nicht stimmt. Schauen wir uns das Minimalbeispiel aber genauer an, stellen wir fest, dass die Bäume, wie erwartet, die gleichen Schlüssel-Wert-Paare enthalten, nur anders im Baum verteilt. Aber interessiert uns die Gestalt der Bäume hier wirklich? Nein! Setzen wir einmal voraus, dass alles sich so verhält, wie es sich für Suchbäume gehört – Aspekte, die wir mit anderen Eigenschaften abdecken (müssen) –, dann sehen wir, dass es hier eigentlich nur um den Inhalt der beiden Bäume geht. Unsere Eigenschaft ist einfach zu stark formuliert: Statt Gleichheit der Bäume zu verlangen, genügt Äquivalenz – und diese Eigenschaft stimmt nun wirklich (Listing 11).
Listing 11
def equiv(t1: BinarySearchTree, t2: BinarySearchTree): Boolean =
t1.toList == t2.toList
property("insert insert") =
forAll {(k1: Int, k2: Int, v1: Value, v2: Value,
t: BinarySearchTree) =>
(k1 == k2 ==>
(t.insert(k1, v1).insert(k2, v2) ?=
t.insert(k2, v2))) ++
(k1 != k2 ==>
equiv(t.insert(k1, v1).insert(k2, v2),
t.insert(k2, v2).insert(k1, v1))) }
Zum Abschluss kommen wir zur vielleicht effektivsten Klasse von Eigenschaften, was das Aufspüren von Fehlern betrifft: modellbasierte Eigenschaften. Wir suchen nach einem abstrakten Modell des gewünschten Verhaltens, um dieses als Referenzimplementierung zu verwenden, die das echte Verhalten vollständig spezifiziert, ohne die eigentliche Implementierung zu replizieren.
Wir nehmen uns wieder insert vor. Gibt es ein vereinfachtes Modell für die Wirkung von insert auf Suchbäumen? Ja, insert auf Listen. Wir müssen nur zwei Dinge beachten: Auf Listen überschreibt insert einen bereits existierenden Schlüssel nicht. Wir müssen daher sicherstellen, dass der Schlüssel vor dem Einfügen nicht in der Liste enthalten ist; dazu filtern wir ihn vorher heraus. Außerdem implizieren Suchbäume, anders als Listen, eine Sortierung auf ihren Knoten, deshalb sortieren wir die Liste nach dem Einfügen. Damit haben wir tatsächlich ein geeignetes Modell für unsere Suchbaumoperation gefunden:
property("insert model") =
forAll {(k: Int, v: Value, t: BinarySearchTree) =>
t.insert(k, v).toList ?=
(t.toList.filter(_._1 != k) :+ (k, v)).sortBy(_._1) }
Property-based Testing nimmt uns nicht nur einiges an Arbeit ab. Das Vorgehen lädt uns auch dazu ein, auf einer abstrakten, fachlicheren Ebene über unseren Code nachzudenken. Property-Tests zwingen uns, unsere beim Entwickeln oft nur unbewusste und vor allem unscharfe Intuition über das erwartete Systemverhalten herauszuarbeiten. Gelingt es, viele aussagekräftige Eigenschaften zu formulieren, finden wir in unseren Tests nicht nur ein effektives Sicherheitsnetz, das uns vor Fehlern schützt, sondern auch eine sehr reiche Spezifikation, die uns und anderen hilft, zu verstehen, was Korrektheit für das fragliche System genau bedeutet.
Trotz aller Begeisterung wollen wir die Schattenseiten nicht verhehlen: Property-based Testing ist nicht die sprichwörtliche Silberkugel! Schlägt ein Property-Test fehl, ist es oft nicht trivial zu entscheiden, ob unsere Implementierung fehlerhaft ist oder die Eigenschaft. Mit Unit-Tests ist diese Einschätzung meist einfacher. Einzelne Eigenschaften sind nur dann wirklich aussagekräftig und hilfreich, wenn wir das System insgesamt ausreichend spezifiziert haben, also alle relevanten Eigenschaften über Tests abdecken. Doch was sind „alle relevanten“ Eigenschaften? Woher wissen wir, wann es genug ist?
Auch die Zufälligkeit der Testfälle ist manchmal mehr Fluch als Segen: Sind alle wichtigen Konstellationen wirklich ausreichend abgedeckt? Viele Testdurchläufe bedeuten eben nicht zwingend, dass die wirklich interessanten in großer Zahl im Testset enthalten sind. Gegebenenfalls müssen wir die Verteilung der Testdaten anpassen, um realistische Testszenarien zu erhalten. Und auch bei der Fehlersuche ist der Weg über generierte Testfälle nicht immer der beste. Manches Mal führt der Test einiger weniger maßgeschneiderter Beispiele schneller ans Ziel. Vor diesem Hintergrund raten wir zu einer Kombination aus Property- und Einzelfalltests, um die Vorteile beider Welten zu verbinden.
Wir hoffen, dass der Artikel Neugierde und Lust auf mehr wecken konnte und mit den vorgestellten Strategien einiges an nützlichem Handwerkszeug enthält, das den Einstieg in Property-Tests erleichtert.
In diesem Sinne: Happy Testing!
[1] ScalaCheck, Property-based testing for Scala: https://scalacheck.org
[2] ScalaTest: https://www.scalatest.org
[3] specs2: https://etorreborre.github.io/specs2
[4] Source Code zum Artikel: https://github.com/active-group/javamagazin-serie-funktionale-programmierung/tree/main/property-based-testing
[5] Hughes, John: „How to specify it! A guide to writing properties of pure functions“; Lambda Days 2020 (Keynote): https://www.youtube.com/watch?v=G0NUOst-53U
[6] ScalaCheck, API Doc: https://javadoc.io/doc/org.scalacheck/scalacheck_3/latest/index.html
[7] ScalaCheck, User Guide: https://github.com/typelevel/scalacheck/blob/main/doc/UserGuide.md
Was haben eines der bekanntesten Opernhäuser der Welt, eine Zeile aus dem Stück „Die Interimsliebenden“ von Einstürzende Neubauten, Automodelle von Renault sowie Škoda und das Titelthema der heutigen Ausgabe miteinander zu tun? Kleiner Hinweis, es geht nicht ums Einstürzen, was gerade in Bezug auf das erstgenannte Opernhaus überaus schade wäre … Nein, es geht um das kleine Wörtchen Scala, wenn auch im Falle des Neubauten-Songs mit „k“ („Während nur eines Zungenschlags/gibt es Urknall und Wärmetod/Vom roten Riesen bis zum weissen Zwerg/die ganze Skala“) und nicht mit „c“ geschrieben. Nun wissen Sie, wohin die Reise diesen Monat geht, und wir können Oper, Auto etc. beiseitelegen und uns dem Schwerpunkt der aktuellen Ausgabe widmen.
Genau genommen geht es um Scala und das Thema „Funktionale Programmierung“, dessen spannender Inhalt seine Headline-Untauglichkeit oder zumindest die von ihm verursachten Headline-Hindernisse (aufgrund der Länge) aus Redakteurssicht nur schwer verbergen kann. In einer abkürzungsaffinen Zeit schlagen wir daher das Silbenwort „Funpro“ vor. Ob die Autoren Spaß an Scala haben? Wahrscheinlich! Auf jeden Fall aber sehen sie jede Menge Vorteile, die sie in ihrem Schwerpunkt herausstellen. Ebendiesen Spaß lege ich nun mal beiseite (wie bereits Oper und Auto): Natürlich bleiben wir bei „Funktionale Programmierung“.
Wie Christina Zeller und Dr. Michael Sperber in ihrer Einleitung zum Schwerpunkt schreiben, unterstützt die Java-Plattform mit Clojure und Scala zwei leistungsfähige funktionale Sprachen. Dieses Mal beschränken wir uns auf Scala und lassen Clojure mal außen vor – dafür aber ist die Beschäftigung mit Scala umso intensiver: Insgesamt fünf Schwerpunktartikel plus Leitartikel sprechen eine deutliche Sprache, denke ich – „Fünf auf der nach oben offenen Richterskala“ sozusagen, um nochmal die schon oben erwähnte Band zu bemühen, deren fünf- , nein, pardon viertes Album diesen Namen trug.
Aber auch abseits des Schwerpunkts gibt es wie immer einiges zu entdecken: Beispielsweise setzen wir diesen Monat unsere unregelmäßige Reihe zum Thema Flow fort. Mit „Effektiv statt effizient“ hat Uwe Friedrichsen einen spannenden Beitrag geliefert – wer mehr über das Thema wissen möchte, ist herzlich eingeladen, an unserem „Agile Flow Day – Modern Productivity“ teilzunehmen, der am 5. Mai 2025 auf der JAX in der Rheingoldhalle Mainz stattfindet. Selbstverständlich ist das noch lange nicht alles, wie Ihnen stante pede das Inhaltsverzeichnis verraten wird.