Daniel Kolman

TDD Outside-in

| 2 comments |

Moje oblíbená technika psaní unit testů je začít zvenku, od toho, jak se bude vaše třída volat. Je to jako kdyby jste navrhovali veřejnou knihovnu – chcete aby měla rozumné rozhraní a šla dobře používat. Tento přístup klade důraz na návrh tříd a jejich spolupráce. Implementace je až druhořadá záležitost, protože jde vždycky změnit, pokud nebude vyhovovat např. výkonnostně. V dnešním článku bych vám chtěl na příkladu Langtonova mravence ukázat, jak to funguje a jaké má tento přístup výhody a nevýhody.

Langtonův mravenec je zero-player game skládající se z nekonečné 2D plochy černých a bílých buněk a jednoho směrově orientovaného mravence. V každé iteraci (kroku) mravenec udělá:

  • Pokud stojí na bílé buňce, otočí se doprava, změní barvu buňky a popoleze o buňku vpřed.
  • Pokud stojí na černé buňce, otočí se doleva, změní barvu buňky a popoleze o buňku vpřed.

Více informací o tomto problému a animaci prvních 200 kroků najdete na wikipedii.

Řekněme že máme napsat program, který vypíše seznam černých buněk po zadaném počtu iterací. Pokud píšeme kód stylem TDD Outside-in, musíme nejdřív vymyslet, co bude reprezentovat náš program a jak se to bude volat. Mohlo by to vypadat třeba takhle:

var game = new Game();
game.Tick();
var blackCells = game.GetBlackCells();

Třída Game bude implementovat pravidla hry. Metoda Tick() provede jednu iteraci hry. Nakonec metoda GetBlackCells() vrátí seznam černých buněk. Mravenec začíná na bílé nekonečné ploše, takže předpokládáme, že černých buněk bude vždy méně než bílých. Když budeme chtít dostat výsledek po více než jedné iteraci, zavoláme game.Tick() víckrát.

Takže náš první test by mohl vypadat nějak takhle:

public class GameTest
{
    [Test]
    public void ReturnsCorrectResultAfterOneIteration()
    {
        var game = new Game();
        game.Tick();
        var blackCells = game.GetBlackCells();

        Assert.That(blackCells, Is.EquivalentTo(new[] {new Position(0, 0)}));
    }
}

Z testu vyplývá, že jsme se rozhodli pozici na ploše reprezentovat třídou Position, která bude mít dvě souřadnice. Position bude immutable (neměnný) value object, tedy bude obsahovat data, ale žádné chování. Nyní můžeme vytvořit třídy Game a Position, aby šel test vůbec zkompilovat:

public class Game
{
    public void Tick()
    {
        throw new NotImplementedException();
    }

    public IEnumerable<Position> GetBlackCells()
    {
        throw new NotImplementedException();
    }
}

public class Position
{
    public int X { get; private set; }
    public int Y { get; private set; }

    public Position(int x, int y)
    {
        X = x;
        Y = y;
    }
}

A zde je vidět první problém outside-in přístupu: Test nás donutil vytvořit hned dvě třídy najednou. A nejen to, navíc implicitně předpokládá, že pro třídu Position bude platit value equality, takže dva různé objekty Position se stejnými souřadnicemi se budou rovnat. To ale zatím není pravda. Musíme proto napsat další test:

public class PositionTest
{
    [Test]
    public void TwoObjectsWithSameCoordinatesAreEqual()
    {
        var a = new Position(1, 2);
        var b = new Position(1, 2);
        Assert.That(a, Is.EqualTo(b));   
    }
}

Pro stručnost implementaci value equality třídy Position vynechám, kompletní příklad najdete na githubu.

Vsuvka 1: Který Red je správný Red?

Nyní můžeme spustit náš první test, který dopadne takto:

System.NotImplementedException : The method or operation is not implemented.

Hurá! Máme padající test. Jenže chceme, aby padal zrovna tímto způsobem? Základem TDD je ověřit si, že test který napíšeme spadne. Jenže stejně důležité je, aby padal požadovaným způsobem a aby hláška z pádu testu odpovídala tomu, co testujeme a byla srozumitelná. V testu ReturnsCorrectResultAfterOneIteration() nás zajímá, jestli Game vrací správné černé buňky, a ne jestli je nebo není implemetovaná. Upravíme proto třídu Game:

public class Game
{
    public void Tick()
    {
    }

    public IEnumerable<Position> GetBlackCells()
    {
        return new Position[0];
    }
}

Test nyní skončí s chybou:

Expected: equivalent to < <TddOutsideIn.Position> >
But was:  <empty>

To je o trochu lepší, protože to popisuje výsledek, ale hláška ještě není úplně srozumitelná, protože nevíme, jaký objekt Position jsme to vlastně očekávali. V třídě Position proto doplníme metodu ToString(), aby vracela souřadnice buňky:

public override string ToString()
{
    return string.Format("[{0}, {1}]", X, Y);
}

Když nyní spustíme test znovu, dostaneme konečně rozumnou hlášku:

Expected: equivalent to < <[0, 0]> >
But was:  <empty>

Hurá! Máme padající test, který padá očekávaným způsobem a vypisuje srozumitelnou hlášku.

Green

Můžeme proto přistoupit k implementaci třídy Game. Neměli bychom přitom ale dělat víc, než kolik nám nařizuje test:

public class Game
{
    public void Tick()
    {
    }

    public IEnumerable<Position> GetBlackCells()
    {
        return new[] { new Position(0, 0) };
    }
}

Poněkud zjednodušující, můžete namítnout. Ale test projde, a to je hlavní. Nyní je potřeba napsat další test, abychom měli jistotu, že třída Game vrací správné výsledky i při jiném počtu iterací než jedna.

Vsuvka 2: State Verification vs. Behavior Verification

Předtím si ale musíme udělat malou teoretickou odbočku. Testy se dají psát dvěma odlišnými způsoby, které se liší tím, co ověřujeme. V našem prvním testu se ověřuje stav. Nejdříve se nějak nakonfiguruje prostředí (Game), pak se provede testovaná akce (Tick), a nakonec se ověří, že se prostředí změnilo do kýženého stavu (seznam černých buněk odpovídá očekávání). Kdybychom chtěli dál pokračovat tímto způsobem, museli bychom identifikovat všechny zajímavé konfigurace prostředí, které chceme testovat. V našem případě by to byl mravenec stojící na bílé nebo černé buňce a otočený do jednoho ze čtyř směrů, tj. osm možností. Třídě Game bychom řekli, kde stojí mravenec, na jaké buňce a jak je otočený, pak zavolali metodu Tick() a nakonec ověřili zda je mravenec na správné buňce, má správnou orientaci a barva buněk odpovídá zadaným pravidlům. To je ale jen jednoduchý problém Langtonova mravence, v reálném světě může být možností konfigurace nekonečně.

Až dosud jsme v testu ověřovali stav. Můžeme ale také ověřovat chování. Místo abychom kontrolovali, jak se změnilo prostředí po spuštění metody Tick(), radši se zeptáme, co třída Game dělá během metody Tick(). Nutným předpokladem je identifikace spolupracujících objektů (collaborators), které nahradíme dvojníkem (test double). V našem případě jsme se rozhodli, že Game má implementovat zadaná pravidla hry. Podle pravidel má hra otáčet mravencem podle barvy buňky na které stojí, posouvat ho a měnit barvu buněk. Řekněme, že kolaborátoři třídy Game budou Ant, který bude mít orientaci a polohu, a Board, který bude reprezentovat hrací pole s černými a bílými buňkami. Místo abychom se ptali na stav mravence a hrací plochy na konci testu, budeme ověřovat, že Game na nich zavolá správné metody. K tomu nám pomůžou testovací dvojníci, které v testu použijeme místo skutečných kolaborátorů.

Spolupracující objekty (collaborators) můžeme nahradit několika typy dvojníků. Pro TDD Outside-in jsou zvlášť vhodné mock objekty vytvářené nějakým šikovným mockovacím frameworkem. Díky nim se můžeme plně soustředit na návrh rozhraní spolupracujících objektů a nechat implementaci na později.

Game a spol.

Jak by v našem případě vypadal test, který ověřuje chování místo stavu? Nejdřív je nutné vymyslet kolaborátory třídy Game a jejich rozhraní. Zkusíme nyní otestovat, že se mravenec otočí na bílé buňce doprava. Game tedy musí vědět, zda mravenec stojí na bílé buňce, a pokud ano, otočit ho doprava. Musíme proto vytvořit mock objekty Ant a Board a podstrčit je Game:

[Test]
public void TurnsAntRightOnWhiteCell()
{
    var ant = new Mock<IAnt>();
    var board = new Mock<IBoard>();
    var currentPosition = new Position(0, 0);
    ant.SetupGet(a => a.CurrentPosition).Returns(currentPosition);
    board.Setup(b => b.IsBlack(currentPosition)).Returns(false);

    var game = new Game(ant.Object, board.Object);
    game.Tick();

    ant.Verify(a => a.TurnRight());
}

K mockování je zde použit framework Moq. Tento test definuje, že Ant bude mít property CurrentPosition a metodu TurnRight(), Board metodu IsBlack(), která vrátí, zda je určitá buňka černá. Po zavolání game.Tick() se verifikuje, zda byla zavolána metoda ant.TurnRight(). Ant i Board jsou reprezentované pomocí rozhraní, takže se můžeme soustředit pouze na jejich interakci s třídou Game. K tomu, aby test prošel, nemusíme rozhraní IAnt ani IBoard vůbec implementovat – to za nás obstará mock framework.

Obdobně bychom otestovali otočku doleva, implementace v třídě Game může vypadat takto:

public class Game
{
    readonly IAnt _ant;
    readonly IBoard _board;

    public Game(IAnt ant, IBoard board)
    {
        _ant = ant;
        _board = board;
    }

    public void Tick()
    {
        if (_board.IsBlack(_ant.CurrentPosition))
            _ant.TurnLeft();
        else
            _ant.TurnRight();
    }

    ...
}

Mimochodem, když už máme IBoard, jehož zodpovědností je držet informace o stavu hrací plochy, pak už nedává smysl, aby metoda GetBlackCells() byla v třídě Game. Game obsahuje jen pravidla hry, a objektu IBoard se ptá na barvu buněk a říká mu, u kterých buněk má změnit barvu. Metodu GetBlackCells() jsme proto přesunuli do IBoard.

Další pravidlo říká, že mravenec se má v každé iteraci posunout o buňku vpřed:

[Test]
public void MovesAntForward()
{
    var ant = new Mock<IAnt>();
    var board = new Mock<IBoard>();

    var game = new Game(ant.Object, board.Object);
    game.Tick();

    ant.Verify(a => a.MoveForward());
}

Framework Moq automaticky přijímá všechna nenakonfigurovaná volání, a pokud metody vrací nějakou návratovou hodnotu, vrátí výchozí hodnotu pro daný typ. Díky tomu tento test projde, i když nejsou nakonfigurovaná volání ant.CurrentPosition a board.IsBlack(), stačí do metody Game.Tick() přidat volání _ant.MoveForward(). Na toto výchozí chování se ale nemusíme spoléhat, můžeme vše co je potřeba nastavit v setupu testu, tak jak to uděláme v dalším kroku.

Naše testy nyní obsahují spoustu duplicitních řádků. Setup testů je navíc dost dlouhý a obtížně čitelný. Zkusíme proto test zrefaktorovat. Vše co může být společné pro všechny testy přesuneme do setup metody:

public class GameTest
{
    readonly Position _currentPosition = new Position(0, 0);
    Mock<IAnt> _ant;
    Mock<IBoard> _board;
    Game _game;

    [SetUp]
    public void SetUp()
    {
        _ant = new Mock<IAnt>();
        _board = new Mock<IBoard>();
        _ant.SetupGet(a => a.CurrentPosition).Returns(_currentPosition);
        _game = new Game(_ant.Object, _board.Object);
    }

    [Test]
    public void TurnsAntRightOnWhiteCell()
    {
        _board.Setup(b => b.IsBlack(_currentPosition)).Returns(false);
        _game.Tick();
        _ant.Verify(a => a.TurnRight());
    }

    [Test]
    public void TurnsAntLeftOnBlackCell()
    {
        _board.Setup(b => b.IsBlack(_currentPosition)).Returns(true);
        _game.Tick();
        _ant.Verify(a => a.TurnLeft());
    }
    
    [Test]
    public void MovesAntForward()
    {
        _game.Tick();
        _ant.Verify(a => a.MoveForward());
    }
}

Testy jsou nyní velmi krátké a jednoduché, a měli bychom se snažit, aby to tak vždy zůstalo.

Shrnutí

Metoda TDD Outside-in umožňuje soustředit se na návrh tříd, jejich rozhraní a spolupráce. Díky definici spolupracujících objektů a jejich nahrazení v testu mockem umožňuje začít "zvenku", od použití třídy v klientském kódu.

Všimněte si, že Game vůbec neřeší, co konkrétně znamená "posunout se o buňku vpřed" nebo "otočit se doprava". To není zodpovědnost třídy Game, ta má pouze implementovat zadaná pravidla hry. Přístup outside-in nám umožnil implementovat a otestovat tato pravidla dříve, než jsme museli řešit posun v 2D poli a změnu směru o 90 stupňů.

Testovací styl, při němž se ověřuje chování místo stavu, neustále nutí přemýšlet o tom, co která třída má na starosti. Pokud je test moc dlouhý nebo složitý, je to známkou toho, že máme třídy moc velké a mají na starosti víc než jednu věc.

Má to však i svoje mouchy – třeba než napíšete první test, musíte udělat hodně rozhodnutí o tom, jak budou vaše třídy vypadat: Že bude existovat objekt Game, že bude iterovat hru metodou Tick, že potřebuje dva kolaborátory, kteým jsme předem vyhradili nějakou konkrétní zodpovědnost. Implicitně jsme se také rozhodli, že ani IBoard ani IAnt nebude immutable, protože Game v každé iteraci změní jejich stav.

Díky tomu vnášíte do návrhu svoje vlastní neuvědomělé předsudky o tom, jak má struktura tříd vypadat. Zbavit se jich můžeme metodou "TDD As if you meant it", na kterou se podíváme příště.

Pokud chcete vědět víc o stylech testování a mock objektech, doporučuju článek Martina Fowlera Mocks Aren't Stubs. Kompletní implementaci Langtonova mravence v C# je na githubu.

(2) Comments

  1. rarous said...

    > A zde je vidět první problém outside-in přístupu: Test nás donutil vytvořit hned dvě třídy najednou.

    > Nenapíšeš řádky testovacího kódu, pokud již nejsou aktuální řádky schopné projít (nezkompilovatelný kód také neprojde).

    22. března 2012 8:15
  2. Marian Schubert said...

    Hezky clanek. Budu se tesit na pokracovani.

    22. března 2012 19:03

Leave a Response