22 May 2008 MS Test, TDD, Test, Unit Tests, Visual Studio 2008 Robert Muehsig

Einführung

Das Konzept der UnitTests (es gibt noch ein paar weitere Formen) ist bereits seit etlichen Jahren (oder Jahrzehnten?) bekannt. Es gibt viele Testframeworks für fast jede Sprache.
Im Visual Studio 2008 (jedenfalls der Professional/Team System Edition) sind UnitTests sehr einfach zu erstellen - und trotzdem hab ich erst vor kurzem die UnitTests für mich entdeckt. Trotz der Gründe für UnitTests und der Einfachheit kenn ich etliche Projekte, wo diese nicht existieren oder angewendet werden.
Die meisten Entwickler denken, dass UnitTests ziemlich komplex sind und eigentlich unnötig.
Die Gründe der Entwickler sind vielfältig (auch ich hab früher so oder so ähnlich gedacht ;) ) :

  • "Warum nicht einfach ein Konsolenprogramm erstellen oder per Debugger prüfen?"
  • "Ich seh es doch wenn eine bestimmte Komponente nicht funktioniert."
  • "Für den extra Aufwand hab ich leider keine Zeit."
  • "UnitTests klingt doch recht kompliziert, da ist mir die Einarbeitungszeit zu hoch."
  • ...
  • </ul>

    Ich bin kein Experte in UnitTests, allerdings haben sie mich bereits nach wenigen Minuten begeistert :)

    UnitTests sind sehr schnell gemacht

    (Achtung: Diese Aussage nicht auf die Goldwaage legen. Gute und durchdachte UnitTests sind keine leichte Aufgabe - darum gibt es ja z.B. auch eine extra Test Edition von Visual Studio wo sich)
    Aber für den Anfang wollen wir die Behauptung mal so stehen lassen - Einfache Tests können sehr schnell durchgeführt werden)

    Im Visual Studio 2008 wurde ein extra Template für Tests bereitgestellt:

    image

    Zudem kann man direkt in einem Projekt per Kontextmenü auf eine Klasse/Methode ein UnitTest erstellen:

    image

    Aber erst mal zur Grundfrage:

    Warum sollte ich Tests machen?

    Jeder Entwickler will (hoffentlich) gute und funktionstüchtige Software schreiben, die möglichst fehlerfrei ihren Dienst tut.
    In der Zeit wo die Software entwickelt wird, werden sicherlich an vielen Ecken (oder Software-Schichten) Änderungen eingepflegt oder die Applikation muss erweitert werden. Insbesondere in einem Team oder wenn eine größere Umstellung ansteht (Datenbasis wechselt, Logik muss abgeändert werden) wird es kritisch: Laufen alle Komponenten noch wie erhofft?
    Je größer die Anwendung, desto größer wird der Aufwand der Betrieben muss, um sicherzustellen, dass alles noch läuft.
    Ein Test im UserInterface ist zwar machbar, ist allerdings meist sehr anstrengend und zeit intensiv (sollte natürlich auch gemacht werden).
    Es wäre doch viel schöner, wenn die Tests automatisch erfolgen könnten - ohne viel Zeit mit Klicken zu verlieren - auch das die Tests jederzeit ausgeführt werden können wäre doch nett, oder?
    Hier kommen die UnitTests: Genau sowas machen UnitTests (und noch mehr ;) ).

    Stellen wir uns mal vor...

    ... dass wir eine nicht ganz triviale Software haben, welche verschiedene Layer (Data/Business etc.) hat. Die Software funktioniert gut - der Kunde ist zufrieden und als Entwickler fühlt man sich wohl.
    Wie es meistens ist: Der Kunde möchte eine Änderung. Ein neues Attribut soll hier und da angefügt werden, eine Abfragelogik verändert werden und die Validation der Daten soll anders verlaufen.
    Das Problem: Die Änderungen können viele Bereiche betreffen, sodass es leicht passieren kann, dass plötzlich garnichts mehr geht. Aber wo genau hakt es denn? Erstmal überall den Debugger ansetzen und nachverfolgen... hoffentlich übersicht man kein Fehler.
    Ergebnis: An dieser Stelle ist es meist für den Entwickler ein etwas mulmiges Gefühl - wird die Software noch genauso funktionieren wie vorher (natürlich mit den Änderungen)?

    ... nun mal mit Tests vorstellen (ein Gedankenspiel) :

    Die verschiedenen Methoden wurden während der Entwicklung der Version 1 bereits mit UnitTests getestet. Daten eintragen, löschen, verändern, laden, validieren, Fehler abfangen usw. - alle Aspekte die wichtig sind, wurden als Test hinterlegt.
    Nun kommen die Änderungen: Es werden einige kritische Bereiche verändert, aber nach jeder Veränderung kann man automatisch alle Tests abspielen - schlägt der Test fehl, weiß man, wo Handlungsbedarf besteht. Die eben gemachte Änderung war wohl anscheinend nicht so gut.
    Nach einer ganzen Weile: Die Tests werden wieder bestanden - das Herz des Entwicklers schlägt höher. Es können zwar immer noch Fehler auftreten (vielleicht muss ein neuer Test für einen neuen Aspekt noch hinzugefügt werden), aber die Grundzüge der Applikation stimmen noch.

    Klingt doch eigentlich gut, aber wie sieht das in der Praxis aus:

    Ein sehr (zugegeben) doofes Beispiel:

        public class DataManager
        {
            public bool ConnectToData()
            {
                return true;
            }
    
            public List<int> GetData()
            {
                return new List<int>() { 1, 2, 3, 4, 5, 6, 7 };
            }
        }

    Unser DataManager kann sich zu einer beliebigen Datenquelle verbinden - in unserem Fall sagen wir einfach mal, dass die Verbindung geklappt hat.
    Die GetData Methode gibt Daten zurück - in unserem Beispiel ein paar statische Daten.

    Da sich die Datenabfrage-Logik ja ändern könnte und da auch die Datenquelle vielleicht sich noch ändert, implementieren wir lieber einen Test dafür:

    Create Unit Test...

    image 
    Methoden auswählen, welche man testen möchte (beide in unserem Fall)...

    image 
    Name eingeben...

    image 

    Ein TestProjekt ist entstanden:

    image

    Generierter Test (dort steht eigentlich bereits das wichtigste drin) :

    Visual Studio nutzt MSTest - das Test-Framework von Microsoft. Es ist ähnlich zu nUnit und co.

    using DoNot.Fear.UnitTests.Data;
    using Microsoft.VisualStudio.TestTools.UnitTesting;
    using System.Collections.Generic;
    
    namespace DoNot.Fear.UnitTests.Test
    {
        
        
        /// <summary>
        ///This is a test class for DataManagerTest and is intended
        ///to contain all DataManagerTest Unit Tests
        ///</summary>
        [TestClass()]
        public class DataManagerTest
        {
    
    
            private TestContext testContextInstance;
    
            /// <summary>
            ///Gets or sets the test context which provides
            ///information about and functionality for the current test run.
            ///</summary>
            public TestContext TestContext
            {
                get
                {
                    return testContextInstance;
                }
                set
                {
                    testContextInstance = value;
                }
            }
    
            #region Additional test attributes
            // 
            //You can use the following additional attributes as you write your tests:
            //
            //Use ClassInitialize to run code before running the first test in the class
            //[ClassInitialize()]
            //public static void MyClassInitialize(TestContext testContext)
            //{
            //}
            //
            //Use ClassCleanup to run code after all tests in a class have run
            //[ClassCleanup()]
            //public static void MyClassCleanup()
            //{
            //}
            //
            //Use TestInitialize to run code before running each test
            //[TestInitialize()]
            //public void MyTestInitialize()
            //{
            //}
            //
            //Use TestCleanup to run code after each test has run
            //[TestCleanup()]
            //public void MyTestCleanup()
            //{
            //}
            //
            #endregion
    
    
            /// <summary>
            ///A test for GetData
            ///</summary>
            [TestMethod()]
            public void GetDataTest()
            {
                DataManager target = new DataManager(); // TODO: Initialize to an appropriate value
                List<int> expected = null; // TODO: Initialize to an appropriate value
                List<int> actual;
                actual = target.GetData();
                Assert.AreEqual(expected, actual);
                Assert.Inconclusive("Verify the correctness of this test method.");
            }
    
            /// <summary>
            ///A test for ConnectToData
            ///</summary>
            [TestMethod()]
            public void ConnectToDataTest()
            {
                DataManager target = new DataManager(); // TODO: Initialize to an appropriate value
                bool expected = false; // TODO: Initialize to an appropriate value
                bool actual;
                actual = target.ConnectToData();
                Assert.AreEqual(expected, actual);
                Assert.Inconclusive("Verify the correctness of this test method.");
            }
        }
    }
    

    Die Kommentare und auch den TestContext kann man löschen - ich hab ihn bisher nicht gebraucht ;)
    Achtung: Ich bin kein Experte in den Unit Tests - sondern ist nur eine Art Erfahrungsbericht :)

    Machen wir doch erstmal einen einfachen Test ob die Verbindung klappt:

            [TestMethod()]
            public void DataManager_ConnectToData_IsTrue()
            {
                DataManager man = new DataManager();
                Assert.IsTrue(man.ConnectToData());
            }

    Sehr schlicht, aber genau das was ich wissen muss. Der Name des Tests sollte ungefähr das beschreiben was er macht - damit man sich später noch zurechtfindet. In diesem Fall prüfe ich einfach, ob die Verbindung zustande kommt.
    Die Assert-Klasse hat mehrere Methoden:

    image

    Jetzt können wir diesen Test durchlaufen und sehen:

    image

    Jetzt prüfen wir noch die andere Methode:

            [TestMethod()]
            public void DataManager_GetData_IsNotNull()
            {
                DataManager man = new DataManager();
                Assert.IsNotNull(man.GetData());
            }
    
            [TestMethod()]
            public void DataManager_GetData_CheckForZero()
            {
                DataManager man = new DataManager();
                List<int> result = man.GetData();
                foreach (int number in result)
                {
                    Assert.AreNotEqual(0, number);
                }
            }

    Die erste Methode prüft, ob überhaupt Werte zurückkommen. Mit dem zweiten Test wollte ich nur mal eine primitive Business-Logik Test machen ("kein Element darf 0 sein").

    Jetzt kann man alle Abspielen:

    image

    Ergebnis:

    image

    Schön, oder? :)

    Ein Gedankenspiel:

    Angenommen in unseren Daten schleicht sich tatsächlich eine 0 ein (Datenabfrage könnte zum Beispiel falsch sein oder es wurden falsche Daten eingetragen), dann schauen wir mal was passiert:

    image

    Ergebnis:

    image

    Fail!

    image

    Idealerweise sollten Tests möglich häufig (sie können sogar automatisch nach jedem Build laufen!) machen - um die Fehlerquelle einzugrenzen.
    Angenommen wir haben bei uns einen Fehler in der Abfragelogik oder die Methode (die bei uns nicht existiert, aber existieren könnte) die Daten schreibt, war fehlerhaft oder die Validation fehlgeschlagen ist... (es kann ja viele Quelle geben).

    Wir beheben also diesen Fehler (den wir vielleicht sonst nur sehr schlecht gefunden hätten), bis wir wieder das sehen:

    image

    Resultat beim Entwickler (& beim zufriedenen Kunden) :

    image

    Der Testcode:

    [TestMethod()]
            public void DataManager_ConnectToData_IsTrue()
            {
                DataManager man = new DataManager();
                Assert.IsTrue(man.ConnectToData());
            }
    
            [TestMethod()]
            public void DataManager_GetData_IsNotNull()
            {
                DataManager man = new DataManager();
                Assert.IsNotNull(man.GetData());
            }
    
            [TestMethod()]
            public void DataManager_GetData_CheckForZero()
            {
                DataManager man = new DataManager();
                List<int> result = man.GetData();
                foreach (int number in result)
                {
                    Assert.AreNotEqual(0, number);
                }
            }

    Ergebnis:

    Die Vorteile von UnitTests werden sicherlich erst nach und nach bei einem Projekt sichtbar, aber wenn man dies stetig fortführt, reduziert sich die Fehleranfälligkeit erheblich.
    Das was ich hier gezeigt habe, ist sicherlich nicht das Ende der Fahnenstange - es gibt neben Unit Tests auch noch andere Tests. Das ist auf der MSDN Testing Seite recht gut beschrieben.

    Test Driven Development (TDD) :

    TDD beschreibt ein Entwicklungsstil, wo auf Tests besonders viel Wert gelegt wird. Hier werden die Tests immer vor der eigentlichen Implementation geschrieben. Man trifft seine Annahmen und da die Methode (oder die zu testende Komponente) ja noch keine Logik enthält, wird der Test erst fehlschlagen.
    Nun geht es darum, den Test erfolgreich zu bestehen. Sobald dies geschafft ist, kann man die Implementation hinterher nochmal überarbeiten. (Refactoring). Nun kann man immer wieder prüfen, ob der Test noch funktioniert oder nicht - wenn er nicht mehr stimmt, dann haben wir wohl was falsch gemacht.
    Am Ende haben wir (in der Theorie) jede Methode / Komponente mit Tests ausgestattet.

    Unit Tests in ASP.NET MVC, Silverlight & co.:

    Eine Klassenbibliothek lässt sich relativ leicht testen. In ASP.NET (WebForms) ist dies allerdings nicht ganz so leicht. In ASP.NET MVC wurde darauf ein besonderer Augenmerk gelegt.
    Auch in Silverlight 2 wurde das Thema angegangen.

    Weitere Links:

    Wer nun etwas neugierig geworden ist, der kann sich auch diese Links anschauen:


Written by Robert Muehsig

Software Developer - from Saxony, Germany - working on primedocs.io. Microsoft MVP & Web Geek.
Other Projects: KnowYourStack.com | ExpensiveMeeting | EinKofferVollerReisen.de