| Parameter | Kursinformationen |
|---|---|
| Veranstaltung: | Vorlesung Softwareentwicklung |
| Teil: | 19/27 |
| Semester | @config.semester |
| Hochschule: | @config.university |
| Inhalte: | @comment |
| Link auf den GitHub: | https://github.com/TUBAF-IfI-LiaScript/VL_Softwareentwicklung/blob/master/19_Testen.md |
| Autoren | @author |
{{0-1}}
Zu Erinnerung an die bereits diskutierten Softwarefehler ...
1999 verpasste die NASA-Sonde Mars Climate Orbiter den Landeanflug auf den Mars, weil die Programmierer unterschiedliche Maßsysteme verwendeten (ein Team verwendete das metrische und das andere das angloamerikanische) und beim Datenaustausch es so zu falschen Berechnungen kam. Eine Software wurde so programmiert, dass sie sich nicht an die vereinbarte Schnittstelle hielt, in der für den Impuls die metrische Einheit Newtonsekunde (N·s) festgelegt war – geliefert wurden jedoch Werte in der angloamerikanischen Einheit Pound-force-Sekunde (lbf·s). Die NASA verlor dadurch die Sonde. Quelle
Softwarefehler sind sowohl sicherheitstechnisch wie ökonomisch ein erhebliches Risiko. Das Consortium for Information & Software Quality (CISQ) beziffert die Kosten schlechter Softwarequalität in den USA für das Jahr 2022 1:
- Ca. 2,41 Billionen US-Dollar betragen die jährlichen Gesamtkosten schlechter Softwarequalität (fehlerhafte Software, gescheiterte IT-Projekte, Betriebsstörungen).
- Ca. 1,52 Billionen US-Dollar davon entfallen auf aufgelaufene technische Schulden (technical debt) – also den Aufwand, um über die Zeit angehäufte strukturelle Mängel im Code nachträglich zu beheben.
{{1-2}}
Was sind Softwarefehler eigentlich?
Ein Programm- oder Softwarefehler ist, angelehnt an die allgemeine Qualitätsterminologie. Die Norm DIN EN ISO 9000:2015 unterscheidet dabei:
Nichtkonformität – „Nichterfüllung einer Anforderung“ (3.6.9)
Fehler (defect) – „Nichtkonformität in Bezug auf einen beabsichtigten oder festgelegten Gebrauch“ (3.6.10)
Der Unterschied liegt im Gebrauchsbezug: Jeder Fehler ist eine Nichtkonformität, aber nicht jede Nichtkonformität ist ein Fehler.
| Situation | Nichtkonformität? | Fehler (defect)? |
|---|---|---|
| Variablennamen verletzen den vereinbarten Coding-Standard (Funktion ist korrekt) | ja | nein |
| Pflichtangabe „PLZ“ wird nicht validiert – ein Bestellformular akzeptiert leere Felder | ja | ja |
| Die Doku nennt eine veraltete Versionsnummer, das Programm läuft aber wie spezifiziert | ja | nein |
| Eine Zinsberechnung rundet falsch und überweist Kunden zu wenig Geld | ja | ja |
Die ersten beiden Beispiele verletzen jeweils eine Anforderung (Coding-Standard bzw. aktuelle Doku), beeinträchtigen aber nicht den beabsichtigten Gebrauch der Software – es sind Nichtkonformitäten, aber keine Fehler im engeren Sinn. Die anderen beiden wirken sich direkt auf die Nutzung aus und sind damit Fehler.
Konkret definiert sich der Fehler danach als
„Abweichung des IST (beobachtete, ermittelte, berechnete Zustände oder Vorgänge) vom SOLL (festgelegte, korrekte Zustände und Vorgänge), wenn sie die vordefinierte Toleranzgrenze [die auch 0 sein kann] überschreitet.“
Im Rahmen dieser Veranstaltung lassen wir Lexikalische Fehler und Syntaxfehler außen vor. Diese sind in der Regel über den Compiler identifizierbar. Darüber hinaus existieren aber :
| Fehlertyp | Folgen |
|---|---|
| Spezifikations-/Anforderungsfehler | Die Software tut korrekt das Falsche – die Anforderung selbst war falsch, unvollständig oder missverständlich. Entsteht, bevor eine Zeile Code geschrieben ist. |
| Logische / semantische Fehler | Anweisung ist zwar syntaktisch fehlerfrei, aber inhaltlich trotzdem fehlerhaft (plus statt minus, kleiner statt kleiner gleich, falsche Schleifengrenze, usw.) |
| Designfehler | Strukturelle Mängel auf der Modul- oder Systemebene, die das Zusammenspiel der Komponenten, deren Erweiterung, usw. verhindern. |
| Nebenläufigkeitsfehler | Race Conditions, Deadlocks, fehlende Synchronisation – treten oft nicht-deterministisch und nur unter bestimmten Timing-Bedingungen auf. |
| Fehler im Bedienkonzept | Unintuitive Benutzung, das Programm "fühlt sich komisch an" |
Darüber hinaus ist es wichtig zwischen Laufzeit- und Designzeitfehlern zu unterscheiden.
{{2-3}}
Wann entstehen Fehler im Projekt?
| Phase | Typische Fehlerquellen |
|---|---|
| Problem- und Systemanalyse | Anforderungen und Qualitätsmerkmale werden nicht festgelegt; es fehlen eindeutige Begriffsdefinitionen. |
| Systementwurf | Die Systemarchitektur ist gar nicht oder nur mit großem Aufwand erweiterbar; das System ist nicht modular aufgebaut, die Daten sind nicht gekapselt. |
| Feinentwurf | Schnittstellen sind nicht hinreichend spezifiziert; Interaktionsmodelle weisen Lücken auf. |
| Codierung | Programmier-Standards bzw. -Richtlinien werden nicht beachtet; die Namensvergabe ist ungünstig. |
| Betrieb und Wartung | Die Dokumentation fehlt ganz, ist veraltet oder nicht adäquat; die Schulung der Anwender wird vernachlässigt; das Konfigurationsmanagement ist unzureichend. |
^
Fehler- | +---------+
kosten | | |
| | |
| | |
| | |
| | +----------+
| | | |
| | | +----------+
| | | | +----------+
| | | | | +---------+
| | Analyse | System- | Imple- | Integra- | Betrieb |
| | | entwurf | mentier- | tion & | und |
| | | | ung | Tests | Wartung |
----------------------------------------------------------->
Software Lebenszyklus .
{{0-1}}
Welche Tests werden in das Projekt integriert?
+------------------------------------------------------> Zeit
|
| Analyse Abnahmetest KUNDE
| \ ^
| v / -.
| Grobentwurf Systemtests |
| \ ^ |
| v / |
| Feinentwurf Integrationstests \ ENTWICKLER
Detail- | \ ^ /
grad | v / |
| Implementierung --> Modultests |
| -'
v
| Bezeichnung | Ebene | Durchführender / Ziel |
|---|---|---|
| Modultest, Komponententest oder Unittest | Funktionalität innerhalb einzelner abgrenzbarer Teile der Software (Module, Programme oder Unterprogramme, Units oder Klassen) | häufig durch den Softwareentwickler selbst, Nachweis der technischen Lauffähigkeit und korrekter fachlicher (Teil-) Ergebnisse |
| Integrationstest, Interaktionstest | Zusammenarbeit voneinander abhängiger Komponenten | Testschwerpunkt liegt auf den Schnittstellen der beteiligten Komponenten und soll korrekte Ergebnisse über komplette Abläufe hinweg nachweisen |
| Systemtest | Gesamtes System wird gegen die gesamten Anforderungen (funktionale und nicht-funktionale Anforderungen) getestet | Test in einer Testumgebung statt / wird mit Testdaten durchgeführt - Simulation einer realistischen Umgebung |
| Abnahmetest, Verfahrenstest, Akzeptanztest | Testen der gelieferten Software durch den Kunden | Rechtlich bindende Evaluation der Software und deren Bezahlung, unter Umständen bereits auf der Produktionsumgebung mit Kopien aus Echtdaten durchgeführt |
{{1-2}}
Warum geht es dann trotzdem schief?
- Es ist angeblich keine Zeit für systematische Tests vorhanden (Termindruck).
- Die Notwendigkeit für systematische Tests wird nicht erkannt.
- Die Tests werden manuell realisiert.
- Die Erstellung von Testspezifikationen für systematische Tests wird nicht entwicklungsbegleitend durchgeführt.
- Die Testebenen weisen eine unterschiedliche Realisierung auf (Modultests top, Systemtests flop)
Es gibt unterschiedliche Definitionen für den Softwaretest:
„the process of operating a system or component under specified conditions, observing or recording the results and making an evaluation of some aspects of the system or component.“ [ursprünglich ANSI/IEEE Std. 610.12-1990, heute abgelöst durch ISO/IEC/IEEE 24765]
„Test […] der überprüfbare und jederzeit wiederholbare Nachweis der Korrektheit eines Softwarebausteines relativ zu vorher festgelegten Anforderungen“ ist. 2
"Unter Testen versteht man den Prozess des Planens, der Vorbereitung und der Messung, mit dem Ziel, die Eigenschaften eines IT-Systems festzustellen und den Unterschied zwischen dem tatsächlichen und dem erforderlichen Zustand aufzuzeigen. 3
Welche Unterschiede sehen Sie in den Definitionen?
Ein erkenntnistheoretischer Hinweis ...
Beachten Sie insbesondere den Anspruch der mittleren Definition, die Korrektheit nachzuweisen. Testen kann das streng genommen nicht leisten – es kann immer nur die Anwesenheit von Fehlern zeigen, nie deren Abwesenheit:
„Program testing can be used to show the presence of bugs, but never to show their absence!“ [Edsger W. Dijkstra]
Die Definition von Pol et al. ist hier vorsichtiger formuliert: Sie spricht vom Aufzeigen des Unterschieds zwischen Soll- und Ist-Zustand – nicht vom Beweis der Korrektheit.
UVerifikation und Validierung?
- Verifikation ... ist der Prozess, der sicherstellt, dass ein Softwareprodukt die Spezifikationen erfüllt und korrekt implementiert wurde. (Bauen wir das Produkt richtig?)
- Validierung ... ist der Prozess, der sicherstellt, dass das Softwareprodukt die Bedürfnisse des Kunden erfüllt und die richtige Software entwickelt wurde. (Bauen wir das richtige Produkt?)
V&V (das Ziel) und die Frage nach der Programmausführung (die Methode) sind zwei unabhängige Dimensionen. Bei der Methode unterscheidet man statisch (ohne Ausführung – Reviews, statische Analyse, formaler Beweis) und dynamisch (durch Ausführung mit konkreten Eingaben). Testen ist die dynamische Methode und kann je nach Teststufe beide Ziele bedienen – Modultest (Verifikation) bis Abnahmetest (Validierung).
+------------------------------------------------+
| |
+-----+-----+ +-----------+ +------------+ | +------------+
+->| Testfälle |-+ +->| Testdaten |-+ +->| Ergebnisse |-+ | +->| Protokolle |
| +-----------+ | | +-----------+ | | +------------+ | | | +------------+
| | | | | | | |
| | | | | | | |
| | | | | | | |
| v | v | v v |
.---------------. .---------------. .--------------. .---------------.
| Entwerfen der |->| Erstellen der |->| Testaus- |->| Vergleich der |
| Testfälle | | Testdaten | | führung | | Ergebnisse |
.---------------. .---------------. .--------------. .---------------.
Abbildung motiviert durch 4
- Entwerfen der Testfälle
- Analyse der Anforderungen, Dokumentationen um erforderliche Testbedingungen festzulegen
- Nachvollziehbarkeit der Entscheidungen, Weiterentwicklung bei Anpassungen in den Anforderungen bzw. der Spezifikation
- Spezifizieren der Testfälle
- Ausarbeitung der eigentlichen Beschreibung der Testfälle und Testdaten
- Definition der erwarteten Resultate
- Testausführung
- (variable) Reihung der Testfälle unter Berücksichtigung von Vor- und Nachbedingungen um Quereffekte abzubilden
- Evaluation der Ergebnisse
Prüfmethoden
|
+-----------------+------------------+
| |
statisch dynamisch
| |
+-----------+----+ +----------------+----------------+
| | | | |
verifi- analy- struktur- spezifikations- diversifi-
zierend sierend orientiert orientiert zierend
(white box) (black box)
|
+-------+--------+
| |
kontrollfluss- datenfluss-
orientiert bezogen .
Abbildung motivierte aus 5
Statische Code Analysen
... ohne eine Ausführung allein anhand des Codes durchgeführt. Der Quelltext wird hierbei einer Reihe formaler Prüfungen unterzogen, bei denen bestimmte Sorten von Fehlern entdeckt werden können, noch bevor die entsprechende Software (z. B. im Modultest) ausgeführt wird. Die Methodik gehört zu den falsifizierenden Verfahren, d. h., es wird die Anwesenheit von Fehlern bestimmt.
-
Codeanalyse ... In Anlehnung an das klassische Programm Lint wird der Vorgang der Analyse eines Codefragments auch als linten (englisch linting) bezeichnet.
Das folgende Beispiel zeigt die Ausgabe des Tools SonarLinter angewendet auf die initiale Implementierung einer Konsolenanwendung unter Visual Studio 2017. Welche Fehler können Sie ausmachen?
https://learn.microsoft.com/de-de/dotnet/fundamentals/code-analysis/overview?tabs=net-9
-
Codereviews ... Reviews sind manuelle Überprüfungen der Arbeitsergebnisse der Softwareentwicklung. Jedes Arbeitsergebnis kann einer Durchsicht durch eine andere Person unterzogen werden.
Der untersuchte Gegenstand eines Reviews kann verschieden sein. Es wird vor allem zwischen einem Code-Review (Quelltext) und einem Architektur-Review (Softwarearchitektur, insbesondere Design-Dokumente) unterschieden.
https://www.codereviewchecklist.com/
- ...
Dynamische Code Analysen
Dynamische Software-Testverfahren sind bestimmte Prüfmethoden, um mit Softwaretests Fehler in der Software aufzudecken. Besonders sollen Programmfehler erkannt werden, die in Abhängigkeit von dynamischen Laufzeitparametern auftreten, wie variierende Eingabeparameter, Laufzeitumgebung oder Nutzer-Interaktion. Wesentliche Aufgabe der einzelnen Verfahren ist die Bestimmung geeigneter Testfälle für den Test der Software.
-
strukturorientiert ... Strukturorientierte Verfahren bestimmen Testfälle auf Basis des Softwarequellcodes (Whiteboxtest). Dabei steht entweder die enthaltenen Daten oder aber die Kontrollstruktur, die die Verarbeitung der Daten steuert, im Fokus.
-
spezifikationsorientiert ... die sogenannten Black-Box Verfahren werden zum Abgleich des vorgegebenen, spezifizierten und des realen Verhaltens einer Methode genutzt. Beim Modultest wird z. B. gegen die Modulspezifikation getestet, beim Schnittstellentest gegen die Schnittstellenspezifikation und beim Abnahmetest gegen die fachlichen Anforderungen, wie sie etwa in einem Pflichtenheft niedergelegt sind.
-
diversifizierend .. Diese Tests analysieren die Ergebnisse verschiedener Versionen einer Software gegeneinander. Es findet entsprechend kein Vergleich zwischen den Testergebnissen und der Spezifikation statt! Zudem kann im Gegensatz zu den funktions- und strukturorientierten Testmethoden kein Vollständigkeitskriterium definiert werden. Die notwendigen Testdaten werden mittels einer der anderen Techniken, per Zufall oder Aufzeichnung einer Benutzer-Session erstellt.
Nehmen wir an, wir hätten eine Klasse MyMathFunctions mit zwei Methoden implementiert und sollen diese testen ...
static class MyMathFunctions{
//Fakultät der Zahl i
public static int fak(int i) {...}
// Grösstergemeinsamer Teiler von i, j und k
public static int ggt(int i, int j, int k) {...}
}Frage: Mit wie vielen Tests könnten wir die Korrektheit der Implementierung nachweisen?
{{1-2}}
Ein vollständiges Testen aller int Werte (fak() ggt() string) lässt sich nicht einmal die Menge der möglichen Kombinationen darstellen.
Black-Box-Testing ... Grundlage der Testfallentwicklung ist die Spezifikation des Moduls. Die Interna des Softwareelements sind nicht bekannt.
Die Güte der Testfälle ist definiert über die Abdeckung möglicher Kombinationen der Eingangsparameter.
Für Black-Box-Testing existieren unterschiedliche Ausprägungen:
| Methode | Idee | Beispiel (Eingabe Alter für einen Tarif, gültig 18–65) |
|---|---|---|
| Äquivalenzklassenanalyse | Der Eingaberaum wird in Klassen zerlegt, die das Programm vermutlich gleich behandelt. Pro Klasse genügt ein Repräsentant – statt aller Werte nur wenige. | drei Klassen: < 18 (ungültig), 18–65 (gültig), > 65 (ungültig) → z. B. Testwerte 10, 40, 80 |
| Grenzwertanalyse Link | Fehler häufen sich an den Rändern der Äquivalenzklassen. Getestet werden gezielt die Grenzen und ihre direkten Nachbarn. | Werte 17, 18, 19 und 64, 65, 66 – die Übergänge gültig/ungültig |
| Zustandsbasierte Testmethoden | Das Verhalten hängt nicht nur von der aktuellen Eingabe ab, sondern vom Zustand (Vorgeschichte). Getestet werden Zustände und erlaubte/verbotene Übergänge. | Geldautomat: Karte → PIN → Auszahlung; Test: Auszahlung ohne vorherige PIN-Eingabe muss scheitern |
Äquivalenzklassen- und Grenzwertanalyse ergänzen sich typischerweise: Erst werden die Klassen gebildet, dann gezielt deren Grenzen abgetestet.
Problematisch ist dabei, dass spezifische Lösungen, wie zum Beispiel in folgendem Fall. Der Entwickler hat hier beschlossen die Performance der Berechnung der Fakultät zu steigern, um die Performance des Algorithmus für Werte kleiner 5 zu verbessern (hypothetisches Beispiel!).
static class MyMathFunctions{
public int fak (int i){
if ( i==1 ) return 0; // FEHLER: fak(1) == 1, nicht 0!
elseif (i == 2) return 1; // FEHLER: fak(2) == 2, nicht 1!
elseif ... Ergebnisse für 3 und 4 ...
elseif (i == 5) return 120;
else return i * fak(i-1);
}
}Mit den alleinigen Testfällen fak(5)==120, fak(6)==720 und fak(10)==3628800 bleiben mögliche Fehler für fak(1) und fak(2) verborgen.
White-Box-Testing ... beim „quelltextbasierten Testen“ sind die Interna des getesteten Softwareelements bekannt und werden zur Bestimmung der Testfälle verwendet
White-Box-Testing-Verfahren zerlegen das Programm (statisch oder dynamisch) entsprechend dem Kontrollfluss. Die Güte der Testfälle wird danach beurteilt, wie groß der Anteil der abgedeckten Programmpfade ist. Die Bewertung kann dabei anhand differenzierter Metriken erfolgen:
- Zeilenabdeckung
- Anweisungsüberdeckung
- Zweigüberdeckung
- Pfadüberdeckung
- ...
C_0 Anweisungsüberdeckung
Anweisungsüberdeckung (auch
static class MyMathFunctions{
public int fak (int i){ // Anweisung
if ( i==1 ) return 0; // 1
elseif (i == 2) return 1; // 2
elseif ... Ergebnisse für 3 und 4 ... // 3 - 4
elseif (i == 5) return 120; // 5
else return i * fak(i-1); // 6
}
}Der oben genannten Black-Box-Test
static class MyMathFunctions{
public int fak (int i){ // Anweisung
int [] facArray = new int [10]; // 1
facArray[0] = 1; // 2
facArray[1] = 1;
...
facArray[9] = 1; // 9
// besser:
// int [] facArray = new int[] { 1, 3, 5, 7, 9 };
if ( i<10 ) return facArray[i]; // 10 + 11
else return i * fak(i-1); // 12
}
}Mit dem Testfall
C_1 Zweigüberdeckungstest
Der Zweigüberdeckungstest umfasst den Anweisungsüberdeckungstest vollständig. Für den C1–Test müssen strengere Kriterien erfüllt werden als beim Anweisungsüberdeckungstest. Im Bereich des kontrollflussorientierten Testens wird der Zweigüberdeckungstest als Minimalkriterium angewendet. Mit Hilfe des Zweigüberdeckungstests lassen sich nicht ausführbare Programmzweige aufspüren. Anhand dessen kann man dann Softwareteile, die oft durchlaufen werden, gezielt optimieren.
Die Zyklomatische Komplexität gibt an, wie viele Testfälle höchstens nötig sind, um eine Zweigüberdeckung zu erreichen.
static class MyMathFunctions{
public int fak (int i){ // Verzweigungen
int [] facArray = new int [10]; //
facArray[0] = 1; //
facArray[1] = 1;
...
facArray[9] = 1; //
// besser:
// int [] facArray = new int[] { 1, 3, 5, 7, 9 };
if ( i<10 ) return facArray[i]; // 1. Verzweigung
else return i * fak(i-1); //
}
}Mit dem Testfall
C_2 Pfadüberdeckung
Das
C_3 Bedingungsüberdeckungstest
a || b)
und prüfen nicht nur das Gesamtergebnis der Bedingung, sondern die Wahrheitswerte
der einzelnen Teilbedingungen. Je nachdem, wie vollständig diese Teilbedingungen
kombiniert werden, unterscheidet man drei Ausprägungen.
static class MyMathFunctions{
public int fak (int i){ // Verzweigungen
boolean a, b;
if (a || b) { ... }
else { ... }
}
}| Test | Testfälle im Beispiel | |
|---|---|---|
| C_3a | Einfachbedingungsüberdeckungstest | 2 – jede Teilbedingung je einmal true/false, z. B. (a=true, b=false) und (a=false, b=true). Deckt nicht zwingend beide Zweige ab! |
| C_3b | Mehrfachbedingungsüberdeckungstest |
|
| C_3c | minimaler Mehrfachbedingungsüberdeckungstest |
|
Important
Die Überdeckung eines Codes lässt sich nicht durch eine Kennzahl ausdrücken, sondern erst durch das Zusammenspiel der Kriterien
Im Folgenden werden wir Attribute als Hilfsmittel verwenden. Entsprechend soll an dieser Stelle ein kurzer Einschub die Möglichkeiten dieser Zuordnung von Metainformationen zum C# Code verdeutlichen.
Attribute erlaube es Zusatzinformationen oder Bedingungen in Code (Assemblys, Typen, Methoden, Eigenschaften usw.) einzubinden. Nach dem Zuordnen eines Attributs zu einer Programmentität kann das Attribut zur Laufzeit mit einer Technik namens Reflektion abgefragt werden.
In C# sind Attribute Klassen, die von der Attribute-Basisklasse erben. Alle Klassen, die von Attribute erben, können als eine Art von „Tag“ für andere Codeelemente verwendet werden. Beispielsweise gibt es das Attribut ObsoleteAttribute. Mit diesem Attribut wird gekennzeichnet, dass der Code veraltet ist und nicht mehr verwendet werden sollte.
Beispiele für Standardattribute sind:
| Name | Bedeutung |
|---|---|
[Obsolete], [Obsolete("ThisClass is obsolete. Use ThisClass2 instead.")] |
|
[Conditional("Test")] |
Wenn die Zeichenfolge nicht einer #define-Anweisung entspricht, werden alle Aufrufe dieser Methode (aber nicht die Methode selbst) durch den C#-Compiler entfernt. |
Attribute werden in rechteckigen Klammern den jeweiligen Codeelementen vorangestellt. Es können mehrere davon kombiniert werden.
#define CONDITION1
#define CONDITION2
using System;
using System.Diagnostics;
class Test
{
static void Main()
{
Console.WriteLine("Standard Code ");
Method0(0);
Console.WriteLine("Calling Method1");
Method1(3);
Console.WriteLine("Calling Method2");
Method2();
}
public static void Method0(int x)
{
Console.WriteLine("Here we run actual algorithm.");
}
[Conditional("CONDITION1")]
public static void Method1(int x)
{
Console.WriteLine("CONDITION1 is defined");
}
[Conditional("CONDITION1"), Conditional("CONDITION2")]
public static void Method2()
{
Console.WriteLine("CONDITION1 or CONDITION2 is defined");
}
}@LIA.eval(["main.cs"], mcs main.cs, mono main.exe)
Die Festlegung der Kompilierungsvorgänge anhand von Hinhalten der eigentlichen Code Dateien scheint "unglücklich". Es bietet sich natürlich an, die zugehörigen Konfigurationen in unsere Projektdateien auszulagern.
using System;
using System.Diagnostics;
class Test
{
static void Main()
{
Console.WriteLine("Standard Code ");
Method0(0);
Console.WriteLine("Calling Method1");
Method1(3);
Console.WriteLine("Calling Method2");
Method2();
}
public static void Method0(int x)
{
Console.WriteLine("Here we run actual algorithm.");
}
[Conditional("CONDITION1")]
public static void Method1(int x)
{
Console.WriteLine("CONDITION1 is defined");
}
[Conditional("CONDITION1"), Conditional("CONDITION2")]
public static void Method2()
{
Console.WriteLine("CONDITION1 or CONDITION2 is defined");
}
}<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<DefineConstants>CONDITION2;CONDITION1;</DefineConstants>
</PropertyGroup>
</Project>
@LIA.eval(["Program.cs", "project.csproj"], dotnet build -nologo, dotnet run -nologo)
using System;
// Zu testende Klasse
public class Calculator
{
public static int DivideTwoValues(double x, double y, ref double result){
if (y != 0){
result = x / y;
return 0;
}
else return -1;
}
}
// Testklasse
public class TestCalculator{
public static void Test_DivideMethod(){
double result = 0;
int state = Calculator.DivideTwoValues(3,4, ref result);
if ((state == 0) & (result == 0.75))
{
Console.WriteLine("Test bestanden !");
}
else{
Console.WriteLine("Test fehlgeschlagen");
}
}
}
// Anwendungsprogramm
public class Program
{
public static void Main(string[] args)
{
//double result = 0;
//int state = Calculator.DivideTwoValues(3,4, ref result);
//Console.WriteLine($"Das Ergebnis lautet {result}, der State {state}.");
TestCalculator.Test_DivideMethod();
}
}@LIA.eval(["main.cs"], mcs main.cs, mono main.exe)
Welche Funktionalität fehlt Ihnen in diesem Setup? Welche weitergehenden Features würden Sie für unsere Testmethoden vorschlagen?
[TestClass] // <-- Framework spezifisch
public class CalculatorTests
{
[TestMethod] // <-- Framework spezifisch
public void TestMethod1()
{
// Arrange
double result = 0;
double x = 3, y = 4;
double expected = 0.75;
// Act
int state = Calculator.DivideTwoValues(x, y, ref result);
// Assert
Assert.AreEqual(expected, result); // Konvention: (expected, actual)
// ^---- Framework specifisch
}
}Vorteile:
- Leistungsfähige API (automatisierte Tests, variable Input-Parameter, Berücksichtigung von Exceptions)
- "Standardisiertes" Nutzungskonzept
- Integration in die Entwicklungsumgebungen
Nachteil:
- verschiedene Interpretationen und Performance der Frameworks
Die wichtigsten Tools unter C# sind xUnit, nunit, MSTest. Einen guten Überblick zum Vergleich der Schlüsselworte liefert Link
Hierzu nutzen wir das xunit Framework. Eine Folge von Tests für unsere DivideTwoValues() Methode könnte dann wie folgt aussehen.
using Xunit;
public class Test_DivideTwoValues
{
[Fact]
public void Check_StateEqualPositiveInputs()
{
// Arrange
double result = 0;
double dividend = 5;
double divisor = dividend;
int expected = 0;
// Act
var state = Calculator.DivideTwoValues(dividend, divisor, ref result);
// Assert
Assert.Equal(expected, state);
}
[Fact]
public void Check_StateZeroAsDivisor()
{
// Arrange
double result = 0;
double dividend = 5;
double divisor = 0;
int expected = -1;
// Act
var state = Calculator.DivideTwoValues(dividend, divisor, ref result);
// Assert
Assert.True(expected == state);
}
[Theory] // Übergabe von variablen Parametersets
[InlineData(10, 2, 5)]
[InlineData(5, 2, 2.5)]
[InlineData(double.MaxValue, double.MaxValue, 1)] // Edge Cases
[InlineData(double.MaxValue, 1, double.MaxValue)]
public void Check_ResultCalculation(double dividend, double divisor, double expected)
{
// Arrange
double result = 0;
// Act
var state = Calculator.DivideTwoValues(dividend, divisor, ref result);
// Assert
Assert.Equal(expected, result);
}
}Bisher haben wir gefragt, ob ein Test läuft. Mindestens ebenso wichtig ist, wie er geschrieben ist. Ein xUnit-Test, der durchläuft, ist nicht automatisch ein guter Test.
Das AAA-Muster (Arrange – Act – Assert)
Ist Ihnen die Kommentarstruktur in allen bisherigen Beispielen aufgefallen? Sie ist kein Zufall, sondern eine etablierte Konvention:
- Arrange ... Testumgebung aufbauen (Objekte, Eingabewerte, erwartete Ergebnisse)
- Act ... die zu testende Aktion einmalig ausführen
- Assert ... das tatsächliche gegen das erwartete Ergebnis prüfen
Ein Test sollte genau eine Sache prüfen. Mehrere Asserts sind erlaubt, wenn sie denselben Sachverhalt absichern — aber ein Test, der drei unabhängige Dinge testet, sagt im Fehlerfall nicht, welches davon kaputt ist.
FIRST-Prinzipien
Gute Unit-Tests erfüllen die fünf FIRST-Kriterien:
| Buchstabe | Bedeutung |
|---|---|
| Fast | schnell genug, um sie bei jeder Änderung laufen zu lassen (Millisekunden, nicht Sekunden) |
| Isolated | unabhängig voneinander — keine geteilte Reihenfolge, kein gemeinsamer Zustand |
| Repeatable | gleiches Ergebnis bei jedem Lauf (kein DateTime.Now, kein Zufall, keine echte Datenbank) |
| Self-validating | bestanden/fehlgeschlagen ist automatisch entscheidbar — kein manuelles Ablesen der Ausgabe |
| Timely | zeitnah geschrieben, idealerweise vor oder mit dem Produktivcode (vgl. TDD) |
Test-Smells — Warnzeichen schlechter Tests
- Logik im Test ...
if/for/Berechnungen im Testcode. Dann testet man den Test mit. Erwartete Werte gehören als Literal in den Test. - Abhängige Tests ... Test B funktioniert nur, wenn Test A vorher lief.
- Test ohne Assert ... ruft nur die Methode auf und prüft nichts (außer "wirft keine Exception").
- Jagd nach der Coverage-Zahl ... Tests, die Zeilen durchlaufen, aber nichts
prüfen, heben die Coverage, nicht die Qualität (siehe
facArray-Beispiel).
Wie setzen wir das Ganze um?
dotnet new sln -o unit-testing-example
cd unit-testing-example
dotnet new classlib -o CalcService // Code der Divisionsoperation einfügen
mv CalcService/Class1.cs CalcService/Division.cs
dotnet sln add ./CalcService/CalcService.csproj
dotnet new xunit -o CalcService.Tests // obigen Testcode einfügen
dotnet add ./CalcService.Tests/CalcService.Tests.csproj reference ./CalcService/CalcService.csproj
dotnet sln add ./CalcService.Tests/CalcService.Tests.csproj
Damit entsteht eine solution, die zwei project umfasst - die eigentliche Anwendung als classlib und die Testfälle.
.
├── CalcService
│ ├── CalcService.csproj
│ ├── Division.cs
├── CalcService.Tests
│ ├── CalcService.Tests.csproj
│ └── UnitTest1.cs
└── unit-testing-example.sln
Das Ausführen der Tests ist nun mit dotnet test möglich.
Eine automatische Generierung von Test Merkmalen ist mit Hilfe zusätzlicher Tools, die in dotnet integriert sind möglich.
dotnet add package coverlet.collector
dotnet tool install --global dotnet-reportgenerator-globaltool
dotnet tool install --global coverlet.console
dotnet test --collect:"XPlat Code Coverage" --results-directory:"./.coverage"
reportgenerator "-reports:.coverage/**/*.cobertura.xml" "-targetdir:.coverage-report/" "-reporttypes:HTML;"
Das Argument "XPlat Code Coverage" bezieht sich auf das Zwischenformat der Darstellung. Das ./.coverage dient zur Angabe des Verzeichnisses, in dem die Ergebnisse gespeichert werden sollen. Wenn keines angegeben wird, wird standardmäßig ein TestResults-Verzeichnis innerhalb jedes Projekts verwendet. reportgenerator erzeugt dann die entsprechende html-Repräsentation.
Brücke zur Theorie: Was der Report als "Line Coverage" ausweist, ist nichts anderes als das weiter oben eingeführte
$C_0$ -Kriterium (Anweisungsüberdeckung). Die Spalte "Branch Coverage" entspricht dem$C_1$ -Kriterium (Zweigüberdeckung).coverletmisst also genau die Metriken, die wir im White-Box-Abschnitt von Hand berechnet haben — nur automatisiert.Achtung: Der Report sagt nicht, ob Ihre Tests gut sind. Erinnern Sie sich an das
facArray-Beispiel — dort erreichte ein einziger Testfall ($i = 1$ ) eine Line Coverage von 91 %, ohne einen einzigen der versteckten Copy-&-Paste-Fehler aufzudecken. Hohe Coverage ist notwendig, aber nicht hinreichend für Qualität.
Im Projektordner finden Sie die gesamte Testimplementierung. Diese wurde um eine Python Applikation erweitert, die eine Sprachübergreifende Nutzung einer Csharp Bibliothek illustriert.
Testen auf Modulebene - reicht das aus?
Testen auf Modulebene ist ein wichtiger Bestandteil der Softwareentwicklung, aber es ist nicht ausreichend, um die Qualität eines gesamten Systems zu gewährleisten. Es deckt nur die kleinsten Einheiten ab und stellt sicher, dass diese korrekt funktionieren. Allerdings können Fehler in der Interaktion zwischen Modulen oder in der Systemintegration unentdeckt bleiben.
| Testart | Fokus | Isolation | Beispiel |
|---|---|---|---|
| Unit-Test | Methode/Funktion | vollständig | Addiere(int a, int b) |
| Modul-/Komponententest | Klasse/Modul | teilweise | Warenkorb.AddArtikel() |
| Integrationstest | mehrere Module | gering | Bestellung -> Lager -> Versand |
| Systemtest | gesamte App | keine | App starten und Bestellung testen |
Modul oder Komponententests sind Tests, die sich auf einzelne Module oder Komponenten einer Software konzentrieren. Sie überprüfen die Funktionalität und das Verhalten dieser Module isoliert von anderen Teilen des Systems.
using Xunit;
public class WarenkorbTests {
[Fact]
public void Test_Gesamtpreis_fuer_mehrere_Artikel() {
// Arrange
var korb = new Warenkorb();
korb.Hinzufügen(new Artikel { Name = "Buch", Preis = 10.0m });
korb.Hinzufügen(new Artikel { Name = "Stift", Preis = 2.0m });
// Act
var gesamt = korb.Gesamtpreis();
// Assert
Assert.Equal(12.0m, gesamt);
}
}In der realen Software bestehen viele Klassen aus Abhängigkeiten zu anderen Komponenten – z. B. Datenbanken, externe Dienste oder Services.
Mocks sind Test-Doubles, mit denen du diese Abhängigkeiten im Test ersetzen kannst, um:
- das Verhalten der Komponente isoliert zu testen
- kontrollierte Rückgaben zu simulieren
- Seiteneffekte zu vermeiden
Warum ist Mocking wichtig?
Ohne Mocks würdest du in jedem Testfall:
- eine echte Datenbank ansprechen
- eine E-Mail versenden
- einen Webservice kontaktieren
Das macht Tests langsam, fehleranfällig und unzuverlässig.
Best Practices beim Mocking
- Mocke nur explizite Abhängigkeiten (nicht alles!)
- Nutze Interfaces oder abstrakte Klassen als Testanker
- Verwende
.Setup(...)nur für erwartetes Verhalten - Nutze
.Verify(...)zur Kontrolle von Aufrufen (z. B. ob ein E-Mail-Versand ausgelöst wurde)
Produktionscode:
public interface IPreisDienst {
decimal GibPreis(string artikelId);
}
public class Kasse {
private readonly IPreisDienst _preisDienst;
public Kasse(IPreisDienst preisDienst) {
_preisDienst = preisDienst;
}
public decimal BerechneGesamtpreis(string artikelId, int menge) {
var einzelpreis = _preisDienst.GibPreis(artikelId);
return einzelpreis * menge;
}
}Testcode mit Mocking - Der Mock ersetzt die Abhängigkeit (IPreisDienst), damit der echte Prüfling (Kasse) unter vollständig kontrollierten Bedingungen getestet werden kann. Wir legen einen generischen Mock an, der als Preisdienst fungiert — und reichen ihn der echten Kasse herein.
using Moq;
using Xunit;
public class KasseTests {
[Fact]
public void BerechneGesamtpreis_mit_MockDienst() {
// Arrange
var mockDienst = new Mock<IPreisDienst>();
mockDienst.Setup(d => d.GibPreis("A1")).Returns(10.0m);
var kasse = new Kasse(mockDienst.Object);
// Act
var gesamt = kasse.BerechneGesamtpreis("A1", 3);
// Assert
Assert.Equal(30.0m, gesamt);
}
}Ein Integrationstest prüft das Zusammenspiel mehrerer Module oder Klassen, z. B.:
- Controller ↔ Service ↔ Datenbank
- UI ↔ Backend ↔ API
- Repository ↔ Domain-Logik
Ziel ist es, Schnittstellenfehler und Zusammenarbeitsprobleme zu erkennen – bevor das System als Ganzes getestet wird.
Produktionscode:
// Domainmodell
public class Artikel {
public int Id { get; set; }
public string Name { get; set; }
public decimal Preis { get; set; }
}
// Repository
public class ArtikelRepository {
private readonly DbContext _ctx;
public ArtikelRepository(DbContext ctx) => _ctx = ctx;
public void Speichern(Artikel a) {
_ctx.Add(a);
_ctx.SaveChanges();
}
public Artikel? Finde(int id) => _ctx.Set<Artikel>().Find(id);
}Testcode mit In-Memory-Datenbank (Fake statt Mock):
Achtung – Abgrenzung zum Unittest oben: Dort haben wir die Abhängigkeit (
IPreisDienst) durch einen Mock ersetzt, der kein eigenes Verhalten besitzt und nur die per.Setup(...)diktierte Antwort liefert. Hier dagegen läuft die echte EF-Core-Logik (Add,SaveChanges,Find, Schlüsselvergabe) – nur das Speichermedium ist durch RAM ersetzt. Ein gemockterDbContextwürde genau das aushebeln, was der Integrationstest beweisen soll: das Zusammenspiel von Repository und Kontext.
using Xunit;
using Microsoft.EntityFrameworkCore;
public class ArtikelRepositoryTests {
[Fact]
public void Speichern_und_Lesen_von_Artikeln() {
// Arrange: In-Memory-Kontext
var options = new DbContextOptionsBuilder<DbContext>()
.UseInMemoryDatabase("TestDB")
.Options;
using var ctx = new DbContext(options);
var repo = new ArtikelRepository(ctx);
var artikel = new Artikel { Name = "Test", Preis = 5.0m };
// Act
repo.Speichern(artikel);
var gelesen = repo.Finde(artikel.Id);
// Assert
Assert.NotNull(gelesen);
Assert.Equal("Test", gelesen?.Name);
}
}Anstatt einen echten Datenbankserver zu verwenden, nutzen wir eine In-Memory-Datenbank für die Tests. Diese ermöglicht aber auch wesentlich konkrete Umsetzungen als die Mock-Objekte, da sie die tatsächliche Datenbank-Logik "simuliert".
Important
Beim Unittest mit Mock ersetzt du die Abhängigkeit vollständig — der Test sagt nichts über deren echtes Verhalten aus, nur über den Prüfling. Beim Integrationstest willst du genau das Zusammenspiel prüfen — deshalb darfst du dort gerade nicht mocken, sondern nutzt einen Fake (In-Memory-DB), der die echte Logik durchlaufen lässt.
Ein Systemtest überprüft das gesamte Systemverhalten aus Sicht des Endnutzers.
Dabei wird die gesamte Anwendung als Black Box getestet – alle Komponenten, Module und Schnittstellen sind integriert.
Ziel: Sicherstellen, dass das System als Ganzes die Anforderungen erfüllt.
| Testart | Fokus | Perspektive |
|---|---|---|
| Unittest | Einzelne Methode | Entwickler |
| Komponententest | Klasse/Modul | Entwickler |
| Integrationstest | Zusammenspiel mehrerer Module | Entwickler |
| Systemtest | Gesamtsystem | Nutzer / Tester |
Eigenschaften von Systemtests
- Arbeiten mit realen oder simulierten Datenbanken, Schnittstellen, UI
- Testen End-to-End-Szenarien (z. B. Anmeldung, Bestellung, Zahlung)
- Häufig automatisiert mit Tools wie Selenium, Playwright oder TestServer
- Können auch manuell durchgeführt werden (z. B. nach Checklisten)
Ein lauffähiges Beispiel mit dem TestServer
Auch ein Systemtest lässt sich automatisieren. .NET bringt dafür die
WebApplicationFactory mit: Sie startet die komplette Anwendung in einem
In-Memory-Webserver (alle Komponenten, Middleware, Routing integriert) und
schickt echte HTTP-Anfragen dagegen — ohne externen Browser oder Server. Der Test
sieht die Anwendung von außen als Black Box, genau aus der Nutzerperspektive.
Produktionscode (eine minimale Web-API):
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/preis/{artikelId}", (string artikelId) =>
artikelId == "A1" ? Results.Ok(10.0m) : Results.NotFound());
app.Run();
public partial class Program { } // macht Program für die Tests sichtbarSystemtest (End-to-End über echtes HTTP):
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class PreisApiSystemTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public PreisApiSystemTests(WebApplicationFactory<Program> factory)
=> _factory = factory;
[Fact]
public async Task GET_Preis_fuer_bekannten_Artikel_liefert_200()
{
// Arrange: gesamte App hochfahren, HTTP-Client holen
var client = _factory.CreateClient();
// Act: echte HTTP-Anfrage gegen die laufende Anwendung
var antwort = await client.GetAsync("/preis/A1");
// Assert: Verhalten aus Nutzersicht
antwort.EnsureSuccessStatusCode();
var preis = await antwort.Content.ReadAsStringAsync();
Assert.Equal("10.0", preis);
}
[Fact]
public async Task GET_Preis_fuer_unbekannten_Artikel_liefert_404()
{
var client = _factory.CreateClient();
var antwort = await client.GetAsync("/preis/UNBEKANNT");
Assert.Equal(System.Net.HttpStatusCode.NotFound, antwort.StatusCode);
}
}Im Gegensatz zum Unit-Test wird hier nichts gemockt oder substituiert: Routing, Modellbindung und Endpunktlogik laufen real zusammen. Das ist der Preis (langsamer, mehr Fehlerquellen) und zugleich der Gewinn (testet, was der Nutzer tatsächlich erlebt) eines Systemtests — genau die Abwägung, die die Testpyramide unten zeigt.
| Kriterium | Methodentest (Unit Test) | Komponententest | Integrationstest | Systemtest |
|---|---|---|---|---|
| Testobjekt | Einzelne Methode oder Funktion | Klasse oder Modul mit internen Abhängigkeiten | Zusammenspiel mehrerer Komponenten/Module | Gesamtsystem (End-to-End) |
| Ziel | Korrektheit der kleinsten Einheit | Zusammenarbeit mehrerer Funktionen | Schnittstellen und Zusammenarbeit testen | Verhalten des Systems aus Sicht des Nutzers |
| Abhängigkeiten | Werden meist gemockt oder isoliert | Können teilweise eingebunden oder ersetzt sein | Echte Implementierungen (z. B. DB, Services) | Realitätsnahe, echte Umgebung |
| Beispiel | CalculateSum(int a, int b) |
UserService mit EmailService |
UserController ↔ UserRepository (mit DB) |
REST-API ↔ Datenbank ↔ Frontend |
| Tools | xUnit, NUnit | xUnit + Moq/Fakes | xUnit + InMemory DB / Testcontainers | Selenium, Postman, TestServer |
| Laufzeit | Sehr kurz | Mittel | Mittel bis lang | Lang |
| Testgeschwindigkeit | 🔵 Schnell | 🟡 Mittel | 🟡 Mittel | 🔴 Langsam |
| Zuverlässigkeit | Hoch (bei guter Isolation) | Mittel (Abhängigkeiten können stören) | Mittel (mehr Fehlerquellen möglich) | Niedriger (viele beteiligte Komponenten) |
| Fehlersuche | Sehr präzise | Eingrenzbar | Eingrenzbar mit Fokus auf Schnittstellen | Schwieriger (viele beteiligte Komponenten) |
| CI/CD-Einsatz | Immer | Häufig | Häufig / nach jedem Build | Selektiv / nachts |
Andere Darstellung
+---------------------------+
| Systemtests | 🔴 Langsam, teuer, realistisch
+---------------------------+
+-----------------------+
| Integrationstests | 🟡 Echte Zusammenarbeit prüfen
+-----------------------+
+-------------------+
| Komponententests | 🟡 Module mit Abhängigkeiten
+-------------------+
+---------------+
| Unittests | 🔵 Schnell, stabil, viele
+---------------+
> 🔺 Je weiter oben, desto aufwändiger
> 🔻 Je weiter unten, desto mehr Tests sollten existieren .
Footnotes
-
Consortium for Information & Software Quality (CISQ): The Cost of Poor Software Quality in the US: A 2022 Report, November 2022. https://www.it-cisq.org/the-cost-of-poor-quality-software-in-the-us-a-2022-report/ ↩
-
Ernst Denert: Software-Engineering. Methodische Projektabwicklung. Springer, Berlin u. a. 1991, ISBN 3-540-53404-0. ↩
-
Martin Pol, Tim Koomen, Andreas Spillner: Management und Optimierung des Testprozesses. Ein praktischer Leitfaden für erfolgreiches Testen von Software mit TPI und TMap. 2., aktualisierte Auflage. dpunkt.Verlag, Heidelberg 2002, ISBN 3-89864-156-2. ↩
-
Ian Sommerville: Software Engineering, Pearson Education, 6. Auflage, 2001 ↩
-
Peter Liggesmeyer, "Software-Qualität - Testen, Analysieren und Verifizieren von Software", Springer, 2002 ↩


