Daniel Kolman

TDD as if You Meant It

| 5 comments |

Tento přístup k TDD se pokouší být jakýmsi "pravým" TDD, které se vrací ke kořenům. Začal ho propagovat (alespoň pokud je mi známo) Keith Braithwaite na různých konferencích (třeba zde) a já jsem se s ním poprvé seznámil na Code Retreat v Berlíně. Z nějakého důvodu se tato technika na Code Retreatech často praktikuje, zřejmě proto, že má potenciál dostat z komfortní zóny i člověka, který pravidelně praktikuje TDD a donutit ho myslet jinak, než je zvyklý.

Disclaimer: Nemám s touto technikou moc velké zkušenosti, protože mi prostě nepřirostla k srdci. Možná mi něco nedochází, ale přijde mi, že kód napsaný tímto způsobem má jisté nedostatky (o tom níže). Budu rád když mě opravíte a napíšete mi, co jsem pochopil špatně.

TDD as if You Meant It je založena na striktním dodržování jednoduchých pravidel:

  1. Napište právě jeden nový test, nejmenší možný, kterým se přiblížíte k řešení.
  2. Ověřte, že test neprojde.
  3. Přímo v testovací metodě napište nejjednodušší implementaci, aby test prošel.
  4. Refaktorujte, odstraňte duplicitu, vylepšete návrh podle potřeby. Přitom striktně dodržujte, že:
    1. Novou metodu lze vytvořit pouze během fáze 4. a to tak, že buď:
      1. Vytvoříte metodu přímo v testovací třídě tak, že do ní přesunete kód z testu (extract method), nebo
      2. Pokud musíte, přesunete kód do již existující metody.
    2. Novou třídu lze vytvořit pouze během fáze 4. a to tak, že do nové třídy přesunete již existující metody z testovací třídy.

Ukážeme si tento postup na zadání z minulého příspěvku. Předem bych rád upozornil, že programování není deterministická činnost, takže to, co zde uvidíte, není jediná možná cesta.

Test

Zkusíme implementovat první pravidlo: Mravenec se na bílé buňce otočí doprava, změní barvu buňky a popoleze o jednu buňku vpřed. Začneme tedy tím, že napíšeme test, že se mravenec na bílé buňce otočí doprava:

[Test]
public void AntTurnsRightOnWhiteCell()
{
    var antOnWhiteCell = true;
    var antOrientation = Orientation.North;

    // implementaci doplníme v kroku 3

    Assert.That(antOrientation, Is.EqualTo(Orientation.East));
}

Test neprojde, ani když doplníme enum Orientation, které je nutné k úspěšné kompilaci. Splnili jsme proto bod 2 a můžeme přistoupit k implementaci, kterou napíšeme přímo do testovací metody:

[Test]
public void AntTurnsRightOnWhiteCell()
{
    var antOnWhiteCell = true;
    var antOrientation = Orientation.North;

    antOrientation = Orientation.East;

    Assert.That(antOrientation, Is.EqualTo(Orientation.East));
}

Tím jsme splnili bod 3. Napsat takhle stupidní implementaci může vypadat, že jsme archetypální ajťáci a pravidlo "do the simplest thing that could possibly work" chápeme příliš doslovně nebo naopak příliš sarkasticky. No, aspoň je teď zjevné, že náš test stojí za h*vn* pokrývá malou část problému. Doplníme proto i ostatní výchozí orientace. Situace si přímo říká o parametrický test:

[TestCase(Orientation.North, Orientation.East)]
[TestCase(Orientation.East, Orientation.South)]
[TestCase(Orientation.South, Orientation.West)]
[TestCase(Orientation.West, Orientation.North)]
public void AntTurnsRightOnWhiteCell(Orientation current, Orientation expectedResult)
{
    var antOnWhiteCell = true;
    var antOrientation = current;

    antOrientation = Orientation.East;

    Assert.That(antOrientation, Is.EqualTo(expectedResult));
}

Implementace přímo v testu

Test teď samozřejmě neprojde (vrátili jsme se do bodu 2), takže musíme přepsat implementaci. Využijeme toho, že enumy v C# jsou v podstatě jen pojmenované číselné hodnoty a že se lze dostat mimo rozsah popsaný prvky enumu. Když jsou prvky enumu ve správném pořadí, můžeme proměnnou typu enum inkrementovat, jako by to bylo číslo:

[TestCase(Orientation.North, Orientation.East)]
[TestCase(Orientation.East, Orientation.South)]
[TestCase(Orientation.South, Orientation.West)]
[TestCase(Orientation.West, Orientation.North)]
public void AntTurnsRightOnWhiteCell(Orientation current, Orientation expectedResult)
{
    var antOnWhiteCell = true;
    var antOrientation = current;

    antOrientation++;
    if (antOrientation > Orientation.West)
        antOrientation = Orientation.North;

    Assert.That(antOrientation, Is.EqualTo(expectedResult));
}

public enum Orientation
{
    North,
    East,
    South,
    West
}

Extrahování metod

Tím jsme splnili podmínky bodu 3. Můžeme přistoupit k bodu 4, refactoring. Vypadá to, že jsme napsali obecnou implementaci otočení směru o 90 stupňů vpravo, tak ji extrahujeme do metody, která bude v testovací třídě:

[TestCase(Orientation.North, Orientation.East)]
[TestCase(Orientation.East, Orientation.South)]
[TestCase(Orientation.South, Orientation.West)]
[TestCase(Orientation.West, Orientation.North)]
public void AntTurnsRightOnWhiteCell(Orientation current, Orientation expectedResult)
{
    var antOnWhiteCell = true;
    var antOrientation = current;

    antOrientation = TurnRight(antOrientation);

    Assert.That(antOrientation, Is.EqualTo(expectedResult));
}

static Orientation TurnRight(Orientation orientation)
{
    orientation++;
    if (orientation > Orientation.West)
        orientation = Orientation.North;
    return orientation;
}

Analogicky bychom postupovali pro TurnLeft.

Možná jste si všimli jedné podivné věci. Chceme testovat, zda se mravenec otočí doprava na bílé buňce, ale proměnnou antOnWhiteCell jsme zatím nikde nepoužili. Přišli jsme totiž na to, že abychom mohli otestovat, že se mravenec na bílé buňce otočí doprava, musíme se nejdřív posunout o úroveň abstrakce níž a umět otáčení doprava. Náš test by se dost dobře mohl jmenovat třeba CanTurnRight a proměnnou antOnWhiteCell vůbec nedeklarovat. A to považuju za jeden z problémů TDD as if You Meant It: Nutí váš mozek přepnout na jinou úroveň abstrakce a řešit detaily implementace dříve, než vyřešíte celý problém na jedné úrovni abstrakce. Místo abychom vyřešili celé pravidlo "mravenec se na bílé buňce otočí doprava", byli jsme donuceni implementovat otáčení samotné a (implicitně) také rozhodnout, jak budeme reprezentovat směr mravence (rozhodli jsme se pro enum, ale mohl by to být třeba vektor – to je ale rozhodnutí, které preferuju odkládat na co nejpozdější okamžik).

Pro srovnání se podívejte na konec předchozího článku o TDD Outside-in. Výsledný test zůstává na jedné úrovni abstrakce a nebylo potřeba řešit jak bude reprezentovaná orientace, pole buněk nebo barva. Dokonce i třídu Position jsme bývali mohli nahradit nějakým rozhraním, a nemuseli jsme řešit ani to, o jaký souřadnicový systém se jedná.

Nyní se můžeme zamyslet nad tím, zda by stálo za to sloučit některé metody do nějaké nové třídy. Vypadá to, že jsme vyřešili problém orientace a otáčení, a tak bychom mohli metody TurnRight a TurnLeft přesunout do nějaké třídy, která by tento problém izolovala. Zkusíme vytvořit třeba třídu Direction:

public class Direction
{
    public static Orientation TurnRight(Orientation orientation)
    {
        orientation++;
        if (orientation > Orientation.West)
            orientation = Orientation.North;
        return orientation;
    }

    public static Orientation TurnLeft(Orientation orientation)
    {
        orientation--;
        if (orientation < Orientation.North)
            orientation = Orientation.West;
        return orientation;
    }
}

Když se budeme držet bodu 4.2 a přesuneme metody do nové třídy, vznikne něco, co spíš než objekt připomíná "helper" nebo "util", tj. třídu která nemá stav a obsahuje metody které jsou statické. Vše co potřebují dostávají v parametrech. A to je další věc, která se mi na TDD as if You Meant It nelíbí. Tato technika má tendenci vytvářet třídy, které jsou bezstavové (nebo mají stavu velmi málo) a metody, které mají mnoho parametrů. Přijde mi to hrozně neobjektové, mám radši třídy, které mají stav a metody s co nejmenším počtem parametrů. Osobně bych to napsal takhle, ale nevím jestli by mi to Keith Braithwaite dovolil:

public class Direction
{
    readonly Orientation _orientation;
    public Orientation Orientation
    {
        get { return _orientation; }
    }

    public Direction(Orientation orientation)
    {
        _orientation = orientation;
    }

    public Direction TurnRight()
    {
        var orientation = _orientation + 1;
        if (orientation > Orientation.West)
            orientation = Orientation.North;
        return new Direction(orientation);
    }

    public Direction TurnLeft()
    {
        var orientation = _orientation - 1;
        if (orientation < Orientation.North)
            orientation = Orientation.West;
        return new Direction(orientation);
    }
}

Nyní třída Direction vypadá o dost lépe, ale zase to vyžaduje poměrně velké úpravy testovacího kódu.

Shrnutí

TDD as if You Meant It je nepochybně technika zajímavá a stojí za to vyzkoušet. Nevěřím ale, že by šla tímto způsobem napsat celá aplikace od začátku do konce. Tendence k bezstavovým třídám a metodám s velkým počtem parametrů je ošklivá, ale dá se potlačit tím, že budete nově vzniklé třídy poctivě refaktorovat.

Testy psané touto technikou nejsou úplně čisté unit testy. Příklad v tomto článku byl příliš krátký, takže to nebylo patrné, ale představte si třeba, že bychom pokračovali s testem, že se mravenec na bílém poli otočí doprava (tentokrát už doopravdy). Test by vypadal nějak takto:

[TestCase(true, Orientation.East)]
[TestCase(false, Orientation.West)]
public void TurnsToCorrectDirection(bool isOnWhiteCell, Orientation expectedResult)
{
    var direction = new Direction(Orientation.North);

    direction = isOnWhiteCell
                        ? direction.TurnRight()
                        : direction.TurnLeft();

    Assert.That(direction.Orientation, Is.EqualTo(expectedResult));
}

Je tohle unit test? Striktně vzato není, protože kromě otáčení podle barvy buňky testujeme implicitně i to, že se Direction umí správně otočit doprava i doleva. Mohli bychom sice Direction schovat za rozhraní, které bychom mockovali a v tomto testu testovali chování místo stavu (viz vsuvka 2 z minulého postu), ale TDD as if You Meant It nás k tomu nijak nenavádí.

Na co se mi tato technika osvědčila jsou explorativní testy – když předem nevíte, jak něco bude fungovat, a potřebujete si to na malém kusu kódu vyzkoušet. V tu chvíli nemá smysl se pokoušet definovat třídy a jejich zodpovědnosti, je lepší nejdřív pochopit, jaké jsou možnosti a jak vůbec lze problém řešit. Jakmile to ale zjistím, vracím se k TDD Outside-in.

(5) Comments

  1. Boris Letocha said...

    Navic tim ze si napsal ten parametricky test, tak to neni jen jeden test, ale rovnou 4, takze si porusil prvni pravidlo :-)

    6. dubna 2012 23:29
  2. rarous said...

    Myslím že nešťastná volba reprezentovat orientation výčtem tě zavedla k implementaci ktera se ti nelibi. A to je IMO dobře. Direction totiž je to samé co Orientation.

    7. dubna 2012 8:58
  3. Marian Schubert said...

    Osobne se snazim zustat v testu co nejdele. Pokud reseni vyzaduje kolaboraci vice objektu, tak vetsinou pouzivam stuby/mocky k tomu abych co nejrychleji prozkoumal problem - http://www.youtube.com/watch?v=Umwnq-Zp414

    je to bohuzel nahrane dost narychlo a bez komentare - pritelkyne mi na background pustila hotel paradise ... asi aby me prinutila jit spat :) gn

    8. dubna 2012 1:16
  4. Daniel Kolman said...

    @Marian cool video! Přijde mi, že tvoje testy trpí stejným neduhem jako moje - místo aby se řešilo pravidlo "na bílé buňce se otoč doprava", řeší se otáčení samotné. To prostě považuju za nedostatek této metody.

    Není to, že od začátku předpokládáš existenci nějakého $ant-a, který bude mít nějaké vlastnosti a metody, proti pravidlům TDD as if You Meant It? Zvlášť patrné je to později v testu, kde se předpokládá dokonce $plane->colorUnderAnt($ant). Tahle metoda má odstranit všechny představy o struktuře tříd, které má člověk předem. A najednou se objeví závislost $ant na $plane.

    9. dubna 2012 23:06
  5. Marian Schubert said...

    Mozna jsem k tomu mohl dat komentar, ze to neni TDD as if You Meant It :) Spis ukazka toho jak bych se k tomu postavil na prvni pokus ja. Kazdopadne je to docela hezke zadani problemu. Urcite si to jeste zkusim nekolikrat naprogramovat. Narocnosti mi to prijde nekde mezi jednoduchou kata (ala String Calculator, Roman numerals) a Game Of Life.

    13. dubna 2012 16:47

Leave a Response