Moje podejście do TDD

Słowem wstępu

Jakiś czas temu zajrzałem na wpis (proponuje się zaznajomić z nim, zanim zaczniecie czytać ten wpis) Jeremy’ego Bytes’a, a mianowicie TDD & Conway’s Game of Life. Opisał on rozwiązanie napisania Gry w życie z użyciem TDD. Wyjaśnił wszystko jasno, a do tego się dobrze to czytało. Jeśli chcecie zasady GoL (Gry w życie) to zapraszam do Jeremy’ego. Nie podobał mi się tylko wynik.

Przytoczę tu tylko końcowy wynik TDD:

Na samym początku wydał mi się jakiś bardzo skomplikowany. Musi być jakiś prostszy algorytm wyznaczania nowego stanu. Czas zabrać się za to osobiście. Dodam tylko, że na początku będę się trzymał klas Jeremy’ego.

No to zaczynamy:

Zawsze zaczynam od najprostszego przypadku, takiego co to ma najwęższy zakres w tym przypadku jest to Any dead cell wit exactly three live neighbours becomes a live cell. Jest najwęższy, ponieważ tyczy się tylko jednej wartości: 3 sąsiadów. Więc test powinien wyglądać tak:

using FluentAssertions;
using Xunit;

namespace GameOfLife.Tests
{
    public class LifeRulesTests
    {
        // When dead cell with exactly 3 live neightburs Then return alive
        [Fact]
        public void When_dead_cell_with_exactly_3_live_neightburs__Then_return_alive()
        {
            var currentState = CellState.Dead;
            var sut = new LifeRules();

            var liveNeighbours = 3;
            CellState actual = sut.GetNewState(currentState, liveNeighbours);
            
            var expected = CellState.Alive;

            actual.Should().Be(expected);
        }
    }
}

Dodajmy klasy. W końcu najpierw powinno się kompilować potem przechodzić test.

namespace GameOfLife
{
    public enum CellState
    {
        Alive,
        Dead
    }
}
namespace GameOfLife
{
    public class LifeRules
    {
        public CellState GetNewState(CellState currentState, int liveNeighbours)
        {
            throw new System.NotImplementedException();
        }
    }
}

No! Teraz się kompiluje! Ale nadal nie przechodzi testu. Coś trzeba z tym zrobić.

namespace GameOfLife
{
    public class LifeRules
    {
        public CellState GetNewState(CellState currentState, int liveNeighbours)
        {
            return CellState.Alive;
        }
    }
}

Mamy pierwszy test!
Teraz trochę oszukamy, bo jakbyśmy zrobili Aby live cell with two or three live neightbours lives to byśmy nic nie musieli zmieniać nic w kodzie produkcyjnym, więc weźmiemy teraz Any live cell with fewer than two live neightbours dies. Więc pora na kolejny test:

[Fact]
public void When_live_cell_has_fewer_than_two_live_neigtbours__Then_return_dead()
{
    var currentState = CellState.Alive;
    var sut = new LifeRules();
            
    var liveNeighbours = 1;
    CellState actual = sut.GetNewState(currentState, liveNeighbours);

    var expected = CellState.Dead;

    actual.Should().Be(expected);
}

Oraz sprawienie, aby przeszedl:

public class LifeRules
{
    public CellState GetNewState(CellState currentState, int liveNeighbours)
    {
        if (liveNeighbours == 1)
        {
            return CellState.Dead;
        }
        return CellState.Alive;
    }
}

Pora na kolejny test? I tak i nie, mamy teraz drugą opcje do poprzedniego testu, zamiast się powtarzać zróbmy Teorie zamiast faktu, czyli testy z paramterami:

[Theory]
        [InlineData(1)]
        [InlineData(0)]
        public void When_live_cell_has_fewer_than_two_live_neigtbours__Then_return_dead(int liveNeighbours)
        {
            var currentState = CellState.Alive;
            var sut = new LifeRules();
            CellState actual = sut.GetNewState(currentState, liveNeighbours);
            var expected = CellState.Dead;
            actual.Should().Be(expected);
        }

Niestety znowu nie przechodzi (mieliśmy dla 1 teraz mamy dla 0 i 1), trzeba to naprawić. W tym celu odwrócimy ifa (teraz Dead ma większy „zasięg”):

public class LifeRules
{
    public CellState GetNewState(CellState currentState, int liveNeighbours)
    {
        if (liveNeighbours == 3)
        {
            return CellState.Alive;
        }
        return CellState.Dead;
    }
}

I znowu wszystko przechodzi! Nie na długo. Kolejny test:

[Fact]
public void When_live_cell_has_3_live_neighbours__Then_return_alive()
{
    CellState currentState = CellState.Alive;
    LifeRules sut = new LifeRules();
    var liveNeighbours = 3;

    CellState actual = sut.GetNewState(currentState, liveNeighbours);

    CellState expected = CellState.Alive;

    actual.Should().Be(expected);
}

Tym razem przechodzi bez zmiany kodu produkcyjnego, bywa i tak. Dodajmy kolejny test.

[Fact]
public void When_live_cell_has_2_live_neighbours__Then_return_alive()
{
    CellState currentState = CellState.Alive;
    LifeRules sut = new LifeRules();
    var liveNeighbours = 2;

    CellState actual = sut.GetNewState(currentState, liveNeighbours);

    CellState expected = CellState.Alive;

    actual.Should().Be(expected);
}

I naprawa.

public CellState GetNewState(CellState currentState, int liveNeighbours)
{
    if (liveNeighbours == 3)
    {
        return CellState.Alive;
    }
    if (liveNeighbours == 2)
    {
        return CellState.Alive;
    }
    return CellState.Dead;
}

Dwa ify jeszcze nie są takie złe… możemy je zostawić na chwilę. Pora na kolejny test (taki, który nie został uwzględniony w przypadkach: Any dead cell with two live neighboars stay dead)

[Fact]
public void When_dead_cell_has_2_live_neighbours__Then_return_alive()
{
    CellState currentState = CellState.Dead;
    LifeRules sut = new LifeRules();
    var liveNeighbours = 2;

    CellState actual = sut.GetNewState(currentState, liveNeighbours);

    CellState expected = CellState.Dead;

    actual.Should().Be(expected);
}

Sprawiamy, aby test przeszedł:

public class LifeRules
{
    public CellState GetNewState(CellState currentState, int liveNeighbours)
    {
        if (liveNeighbours == 3)
        {
            return CellState.Alive;
        }
        if (liveNeighbours == 2 && currentState == CellState.Alive)
        {
            return CellState.Alive;
        }
        if (liveNeighbours == 2 && currentState == CellState.Dead)
        {
            return CellState.Dead;
        }
        return CellState.Dead;
    }
}

Teraz można zrobić refactor. Zauważmy że currentState == CellState.Alive i return CellState.Alive; oraz currentState == CellState.Dead i return CellState.Dead; wyglądają tak samo i jest zwracany stan na wejściu.

public CellState GetNewState(CellState currentState, int liveNeighbours)
{
    if (liveNeighbours == 3)
    {
        return CellState.Alive;
    }
    if (liveNeighbours == 2)
    {
        return currentState;
    }
    return CellState.Dead;
}

Jeszcze parę testów na na ostatni warunek Any live cell with more than three live neighbours dies:

[Theory]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(7)]
[InlineData(8)]
public void When_live_cell_has_more_than_3_live_neighbours__Then_return_dead(int liveNeighbours)
{
    var currentState = CellState.Dead;
    var sut = new LifeRules();
    CellState actual = sut.GetNewState(currentState, liveNeighbours);
    var expected = CellState.Dead;
    actual.Should().Be(expected);
}

I zobaczcie jak to ładnie wygląda. Moim zdaniem dużo prościej niż switch i if. Ale na tym nie skończymy w myśl Wujka Boba postarajmy się zrobić immutable type. Teraz zamiast zwracać CellState, zwrócimy cały Cell. Do tego trzeba będzie zmienić kod testów, ale tak bywa. Podam tylko kod LifeRules, bo testy są takie same.

public Cell GetNewState(CellState currentState, int liveNeighbours)
{
    if (liveNeighbours == 3)
    {
        return new Cell(CellState.Alive);
    }
    if (liveNeighbours == 2)
    {
        return new Cell(currentState);
    }
    return new Cell(CellState.Dead);
}

Lecimy dalej z refactoringiem. Tym razem przekażemy Cell zamiast CellState

public Cell GetNewState(Cell currentCell, int liveNeighbours)
{
    if (liveNeighbours == 3)
    {
        return new Cell(CellState.Alive);
    }
    if (liveNeighbours == 2)
    {
        return currentCell;
    }
    return new Cell(CellState.Dead);
}

I dochodzimy do końca. Skoro mamy immutable type, to nie musimy się martwić o zmianę. Czyli ten Cell, który był na początku żywy, po 100000000 pokoleniach jeśli będzie żywy to niczym się nie będzie różnił od tego na początku. Skoro tak jest to po co tworzyć za każdym razem nowy Cell? No właśnie! Po nic. Dodatkowo pominiemy LifeRules i pozwolimy komórce ewoluować:

public class Cell
{
    public static Cell Alive
    {
        get { return _alive; }
    }

    public static Cell Dead
    {
        get { return _dead; }
    }

    private static readonly Cell _alive = new Cell(CellState.Alive);
    private static readonly Cell _dead = new Cell(CellState.Dead);

    public CellState State { private set; get; }

    protected Cell(CellState state)
    {
        State = state;
    }

    public Cell Evolve(int liveNeighbours)
    {
        if (liveNeighbours == 3)
        {
            return Alive;
        }
        if (liveNeighbours == 2)
        {
            return this;
        }
        return Dead;
    }
}

Teraz wystarczy się pozbyć LifeRules i skończyliśmy. Oto końcowy wynik:

using FluentAssertions;
using Xunit;
using Xunit.Extensions;

namespace GameOfLife.Tests
{
    public class CellTests
    {
        // When dead cell with exactly 3 live neightburs Then return alive
        [Fact]
        public void When_dead_cell_with_exactly_3_live_neightburs__Then_return_alive()
        {
            const int threeLiveNeighbours = 3;

            Cell result = Cell.Dead.Evolve(threeLiveNeighbours);
            
            result.State.Should().Be(CellState.Alive);
        }

        [Theory]
        [InlineData(1)]
        [InlineData(0)]
        public void When_live_cell_has_fewer_than_two_live_neigtbours__Then_return_dead(int liveNeighbours)
        {
            Cell result = Cell.Alive.Evolve(liveNeighbours);

            result.State.Should().Be(CellState.Dead);
        }

        // When live cell has 3 live neighbours Then return alive
        [Fact]
        public void When_live_cell_has_3_live_neighbours__Then_return_alive()
        {
            const int threeLiveNeighbours = 3;

            Cell result = Cell.Alive.Evolve(threeLiveNeighbours);

            result.State.Should().Be(CellState.Alive);
        }

        // When live cell has 2 live neighbours Then return alive
        [Fact]
        public void When_live_cell_has_2_live_neighbours__Then_return_alive()
        {
            const int twoLiveNeighbours = 2;

            Cell result = Cell.Alive.Evolve(twoLiveNeighbours);

            result.State.Should().Be(CellState.Alive);
        }


        [Fact]
        public void When_dead_cell_has_2_live_neighbours__Then_return_alive()
        {
            var twoLiveNeighbours = 2;

            Cell result = Cell.Dead.Evolve(twoLiveNeighbours);

            result.State.Should().Be(CellState.Dead);
        }

        // When live cell has more than 3 live neighbours Then return dead
        [Theory]
        [InlineData(4)]
        [InlineData(5)]
        [InlineData(6)]
        [InlineData(7)]
        [InlineData(8)]
        public void When_live_cell_has_more_than_3_live_neighbours__Then_return_dead(int liveNeighbours)
        {
            Cell result = Cell.Dead.Evolve(liveNeighbours);

            result.State.Should().Be(CellState.Dead);
        }
    }
}

oraz

namespace GameOfLife
{
    public class Cell
    {
        public static Cell Alive
        {
            get { return _alive; }
        }

        public static Cell Dead
        {
            get { return _dead; }
        }

        private static readonly Cell _alive = new Cell(CellState.Alive);
        private static readonly Cell _dead = new Cell(CellState.Dead);

        public CellState State { private set; get; }

        protected Cell(CellState state)
        {
            State = state;
        }

        public Cell Evolve(int liveNeighbours)
        {
            if (liveNeighbours == 3)
            {
                return Alive;
            }
            if (liveNeighbours == 2)
            {
                return this;
            }
            return Dead;
        }
    }
}

Repozytorium

Całość kodu możecie znaleźć na moim githubie. Commity są robione tak jak opis na blogu, więc możecie użyć kodu, aby zobaczyć jak to się zmieniało.

PS

Odnośnie testów: używam R# tempaltes do generowania testu. Test powinien być najpierw czytelny, potem może się stosować do konwencji. Dlatego mam tam wpisane na sztywno 2 razy _ (to nie jest znak podkreślenia, chciałem 2,3 znaki podkreślenia, ale Continuous Tests marudziło o kompilacji, a teraz nawet lepiej wygląda) oraz testy mają podkreślenie.

public class LifeRulesTests
{
    // When Then
    [Fact]
    public void When__Then()
    {
     
    }
}

W //When i Then piszę wyrazy ze spacjami, a są wstawiane w nazwie metody z zamienionymi spacjami na podkreślenie. Dzięki temu w nazwie metody mam bardzo ładne zdania. Dodatkowo QuickFixem zamieniam Fact na Theory
Jak ktoś chce to mogę udostępnić jedno i drugie.

Ten wpis został opublikowany w kategorii TDD i oznaczony tagami , , . Dodaj zakładkę do bezpośredniego odnośnika.

Dodaj komentarz

Bądź pierwszy!

Powiadom o
avatar

wpDiscuz