TheoryCzęść NR.3 Pora pisać kolejne testy by pokazać jak nasza aplikacja się rozrasta. Dziś przetestujemy i napiszemy wymóg sprawdzenie dostępności gry przed jej zakupem.

Mam już test i kod do metody zapisu w naszym repozytorium do zakupów gier. Dla następnego przypadku będziemy musieli stworzyć nowe repozytorium z metodą IsGameAvailable.

Pomyślmy też co powinno się stać w każdym przypadku. Jeżeli gra nie jest dostępna lub nie istnieje to oczywiście nie możemy jej kupić. Jeżeli gra jest dostępna wtedy wykonujemy polecenie zapisu naszego zakupu.

Oto diagram, który pokazuje przepływ takiej aplikacji.

a_24.png

To wszystko. Wskakujemy od razu do kodu. Zaczynamy od nowej metody testowej. Jej nazwa powinna wyrażać wymaganie jak najlepiej. 

[Fact]
public void ShouldNotSaveBoughtGameIfGameIsNotAvailable()
{

}

W tym teście chcemy się upewnić, że metoda Save się nie uruchomiła. Zrobimy to w taki sposób.

[Fact]
public void ShoouldNotSaveBoughtGameIfGameIsNotAvailable()
{
    _processor.BuyGame(_request);

    _repositoryMock.Verify(x => x.Save(It.IsAny<GameBought>()), Times.Never);
}

Tylko teraz nie mamy logiki do sprawdzenia gry więc na tym etapie ten test jest jeszcze nie skończony.

Tworzymy więc kolejny interfejs repozytorium do gier. Dodajemy go folderu Interface.

a_25.png

Dodajemy do naszego repozytorium metodę IsGameAvailable.

public interface IGameRepository
{
    bool IsGameAvailable(Game game);
}

Nie mamy oczywiście klasy reprezentująca samą grę. Korzystamy więc z kreatora w Visual Studio by taką klasę stworzyć w osobnym pliku.

a_26.png

Mam więc klasę Gry. Na razie do gry nie dodałem żadnej właściwości gdyż nie jest to potrzebne.

namespace GameShop.Core
{
    public class Game
    {
 
    }
}

W GameBuyingRequest dodaje pole z grą. W końcu teraz w trakcie zakupu musimy wiedzieć co kupujemy, aby sprawdzić czy możemy to w ogóle kupić.

public class GameBuyingRequest : GameBuyingBase
{
    public Game GameToBuy { get; set; }
}

Teraz stworzymy MOCK na nasze nowe repozytorium w klasie testowej. Zaczynamy od pola prywatnego. W fazie refactoring zmienimy nazwę poprzedniego repozytorium by było jasne który MOCK jest którym repozytorium.

public class GameBuyingRequestProcessorTests
{
    private Mock<IGameBuyingRepository> _repositoryMock;
    private Mock<IGameRepository> _repositoryGameMock;

W konstruktorze tworzymy instancję

public GameBuyingRequestProcessorTests()
{
    _repositoryMock = new Mock<IGameBuyingRepository>();
    _repositoryGameMock = new Mock<IGameRepository>();

Teraz musimy jako ustalić, kiedy gra jest dostępna, a kiedy nie.

Wpadłem na szalony pomysł by określić, że gry zaczynające się znakiem "@" nie będą dostępnymi grami w teście. Jednakże można to rozwiązać jeszcze lepiej bez dzikich kart, które mogłyby zepsuć inne testy.

W konstruktorze mówimy, że domyślnie ta metoda MOCK-owna zwróci pole prywatne naszego testu. Domyślnie te pole ma wartość true więc wszystkie inne testy nie powinny być zagrożone.

private bool _isGameAvailable = true;

public GameBuyingRequestProcessorTests()
{
        _repositoryMock = new Mock<IGameBuyingRepository>();
        _repositoryGameMock = new Mock<IGameRepository>();
        _repositoryGameMock.Setup(x => x.IsGameAvailable(_request.GameToBuy))
    .Returns(_isGameAvailable);

W konstruktorze trzeba też zmienić definicję domyślnego zapytania do procesora. W końcu doszła do niego gra.

// Arrange
_request = new GameBuyingRequest()
{
    FirstName = "Cezary",
    LastName = "Walenciuk",
    Email = "walenciukc@gmail.com",
    Date = DateTime.Now,
    GameToBuy = new Game()
};

Co więcej, trzeba ustawić ustawienie domyślnego requestu wyżej od ustawienia MOCK-a repozytorium gier. Inaczej wszystkie testy przestaną działać.

public GameBuyingRequestProcessorTests()
{
    _repositoryMock = new Mock<IGameBuyingRepository>();
    _repositoryGameMock = new Mock<IGameRepository>();

    // Arrange
    _request = new GameBuyingRequest()
    {
        FirstName = "Cezary",
        LastName = "Walenciuk",
        Email = "walenciukc@gmail.com",
        Date = DateTime.Now,
        GameToBuy = new Game()
    };

    _repositoryGameMock.Setup(x => x.IsGameAvailable(_request.GameToBuy))
        .Returns(_isGameAvailable);

    _processor = new GameBuyingRequestProcessor(_repositoryMock.Object);
}

Testy jednak wciąż mogą źle działać.

Popełniłem tutaj  błąd. Chodzi o metodę Returns w _repositoryGameMock.

Ten mechanizm zwracanej flagi zmiennej zadziałaby, gdyby typ bool był typem referencyjnym. Bool jest typem wartościowym więc jego wartość zostałaby tylko kopiowana.

Na szczęście możemy zmodyfikować metodę Returns i skorzystać z delegaty generycznej Func<T>. Napiszemy wyrażenie lambda, która odwołuje się do naszej flagi. Te odwołanie nie zginie.

a_28.png

 

_repositoryGameMock.Setup(x => x.IsGameAvailable(_request.GameToBuy))
.Returns( () => { return _isGameAvailable; });

W naszej metodzie testowej ustawiamy  flagę _isGameAvailable tymczasowo na false, a potem znowu na true, aby inne testy działały prawidłowo.

[Fact]
public void ShoouldNotSaveBoughtGameIfGameIsNotAvailable()
{
    _isGameAvailable = false;
    _processor.BuyGame(_request);
    _isGameAvailable = true;
    _repositoryMock.Verify(x => x.Save(It.IsAny<GameBought>()), Times.Never);

}

Jeśli wszystko ustawiłeś poprawnie to powinien być czerwony tylko jeden test.

a_27.png

Metoda Save oczywiście uruchomiła, ponieważ nie mamy logiki w kodzie by taki przypadek obsłużyć. Bierzemy się więc do roboty.

Faza Green

Do klasy GameBuyingRequestProcessor dodajemy nasze nowe repozytorium

public class GameBuyingRequestProcessor
{
    private IGameBuyingRepository _repository;
    private IGameRepository _gameRepository;

Naturalnie w konstruktorze musimy przekazać definicję tego repozytorium.

public GameBuyingRequestProcessor(IGameBuyingRepository repository,
    IGameRepository gameRepository)
{
    _repository = repository;
    _gameRepository = gameRepository;
}

Teraz zmodyfikujemy metodę BuyGame tak abyśmy wykonali sprawdzenie IsGameAvailable przed zapisaniem zakupu gry.

public GameBuyingResult BuyGame(GameBuyingRequest request)
{
    if (request == null)
        throw new ArgumentNullException(nameof(request));


    GameBought gameBought = Create<GameBought>(request);

    if (_gameRepository.IsGameAvailable(request.GameToBuy))
        _repository.Save(gameBought);

    var result = Create<GameBuyingResult>(request);

    result.IsStatusOk = true;
    result.Errors = new System.Collections.Generic.List<string>();

    return result;
}

W klasie testowej musimy teraz zmodyfikować konstruktor, a w nim definicję naszego procesora.

_processor = new GameBuyingRequestProcessor(_repositoryMock.Object,
_repositoryGameMock.Object);

Test teraz powinien zadziałać 

a_29.PNG

;

public GameBuyingRequestProcessorTests()
{
    _repositoryMock = new Mock<IGameBuyingRepository>();
    _repositoryGameMock = new Mock<IGameRepository>();

    // Arrange
    _request = new GameBuyingRequest()
    {
        FirstName = "Cezary",
        LastName = "Walenciuk",
        Email = "walenciukc@gmail.com",
        Date = DateTime.Now,
        GameToBuy = new Game()
    };

    _repositoryGameMock.Setup(x => x.IsGameAvailable(_request.GameToBuy))
        .Returns(_isGameAvailable);

    _processor = new GameBuyingRequestProcessor(_repositoryMock.Object);
}

To by było na tyle. Idziemy więc do następnego przypadku.

W trakcie zapisu zakupu chcemy teraz przekazać także ID gry, którą kupiliśmy.

a_32.png

Czy musimy pisać kolejny test? Jest to bardzo mały przypadek i moim zdaniem lepiej zmodyfikować istniejącą metodę testową. 

Dodajemy nową właściwość do GameBought

public class GameBought : GameBuyingBase
{
    public int GameId { get; set; }
}

Dotyczy do także gry

public class Game
{
    public int Id { get; set; }
}

Dodajemy nowy assert do ShouldSaveBoughtGame()

Assert.Equal(_request.GameToBuy.Id, savedgameBought.GameId);

Teraz taka ciekawostka wydaje się, że test przechodzi. A przecież jesteśmy w fazie RED, gdy piszemy test, który powinien nie przejść. Jednakże wynika to z tego, że domyślna wartość dla liczby całkowitej INT to 0. Musimy to poprawić.

// Arrange
_request = new GameBuyingRequest()
{
    FirstName = "Cezary",
    LastName = "Walenciuk",
    Email = "walenciukc@gmail.com",
    Date = DateTime.Now,
    GameToBuy = new Game() { Id = 7}
};

Ustawiamy więc jakieś ID dla naszego zapytania. Test nie działa i tak ma być.

a_33.PNG

Faza Green

Teraz poprawiamy kody by ten test przeszedł. Zmiana nie jest taka skomplikowana. Musimy po prostu przypisać jedno pole na drugie.

public GameBuyingResult BuyGame(GameBuyingRequest request)
{
    if (request == null)
        throw new ArgumentNullException(nameof(request));
    
    
    GameBought gameBought = Create<GameBought>(request);
    gameBought.GameId = request.GameToBuy.Id;

Teraz wszystkie test powinny być na zielono.

Idziemy do kolejnego wymagania. Powinniśmy zwrócić odpowiedni rezultat działania metody BuyGame w zależności od tego, czy daną grę udało się znaleźć. 

Na razie tego nie wiemy.

Kolejny przypadek

Jak pamiętasz w pierwszym wpisie dodaliśmy proste pola określające status działania. Jednak nasze wymagania się trochę zmieniły. Poza tym myślisz, że prosta wartość bool ogarnie wszystkie możliwe przypadki działania kodu?

public class GameBuyingResult : GameBuyingBase
{
    public bool IsStatusOk { get; set; }
    public List<string> Errors { get; set; }
}

Dodajemy typ wyliczeniowy, który określi wszystkie przypadki działania metody BuyGame.

public enum GameBuyingResultCode
{
    Success = 0,
    GameIsNotAvailable = 1,
}

Zauważ, że pomijamy możliwość wystąpienia błędu. Oznacza to, że lista błędów nie będzie nam już potrzebna.

public class GameBuyingResult : GameBuyingBase
{
    public GameBuyingResultCode StatusCode { get; set; }
}

Oznacza to też, że jeden z poprzednich testów musi zostać poprawiony by nadal spełniał swoją rolę.

[Fact]
public void ShouldReturnStatusTrueWhenSendedCorrectValues()
{
    //Act
    GameBuyingResult result = _processor.BuyGame(_request);

    //Assert
    Assert.Equal(GameBuyingResultCode.Success, result.StatusCode);
}

Czy taki test można napisać lepiej? Czy masz pisać metodę testową dla każdego przypadku?

Co powiesz na test kierowany danymi. Data-Driven-Test

Możemy napisać taka metodę testową, która w parametrach będzie przyjmowała określone przez nas wartości.

public void ShouldReturnExpectedResultCode
    (GameBuyingResultCode expectedResultCode,bool IsGameAvailable)
{

}

Co więcej, w tej metodzie nie używamy atrybutu [Fact] tylko atrybutu [Theory].

[Theory]
public void ShouldReturnExpectedResultCode
    (GameBuyingResultCode expectedResultCode,bool IsGameAvailable)
{

}

Zakładamy, że sukces wystąpi, gdy gra jest dostępna. Status GameIsNotAvailable powinien się pojawić, gdy gra nie jest dostępna. Na razie nie omawiam przypadku gdy gry nie ma. To by wymagało dodania kolejnego typu wyliczeniowego, który musiałby być zwracany w metodzie IsGameAvailable. Kto wie może sama metoda IsGameAvailable wtedy powinna zmienić nazwę na "CheckGame".

[Theory]
[InlineData(GameBuyingResultCode.Success, true)]
[InlineData(GameBuyingResultCode.GameIsNotAvailable, false)]
public void ShouldReturnExpectedResultCode
    (GameBuyingResultCode expectedResultCode, bool IsGameAvailable)
{

}

Przypisujemy parametr metody do flagi _isGameAvailable. Później sprawdzamy czy parametr statusu, który podaliśmy będzie zgodny z statusem który otrzymamy.

[Theory]
[InlineData(GameBuyingResultCode.Success, true)]
[InlineData(GameBuyingResultCode.GameIsNotAvailable, false)]
public void ShouldReturnExpectedResultCode
    (GameBuyingResultCode expectedResultCode, bool IsGameAvailable)
{
    _isGameAvailable = IsGameAvailable;

    var result = _processor.BuyGame(_request);

    Assert.Equal(expectedResultCode, result.StatusCode);
}

Jeden z nowych pod testów nie powinien zakończyć się sukcesem. W sumie to, że status sukces ustawia się poprawnie wynika z domyślnej wartości typa wyliczeniowego jaką jest liczba zero.

a_34.PNG

Przechodzimy więc do fazy green i poprawiamy kod tak aby ten test się wykonał.

public GameBuyingResult BuyGame(GameBuyingRequest request)
{
    if (request == null)
        throw new ArgumentNullException(nameof(request));


    GameBought gameBought = Create<GameBought>(request);
    gameBought.GameId = request.GameToBuy.Id;

    var result = Create<GameBuyingResult>(request);
    if (_gameRepository.IsGameAvailable(request.GameToBuy))
    {
        _repository.Save(gameBought);
        result.StatusCode = GameBuyingResultCode.Success;
    }
    else
    {
        result.StatusCode = GameBuyingResultCode.GameIsNotAvailable;
    }

    return result;
}

Do metody BuyGame odpowiednio modyfikujemy obiekt rezultatu w warunkach IF.

Na razie zignorujemy potrzebę obsługi braku gry, którą próbujemy kupić. Przechodzimy do następnego wymagania, a jest nim zwrot Id zakupu gry w rezultacie. Oczywiście nie możemy Id zakupu zwrócić, jeśli nie dokonaliśmy zakupu.

[Theory]
[InlineData(11,true)]
[InlineData(null,false)]
public void ShouldReturnExpectedBoughtGameId(int? expectedPurchaseId, bool IsGameAvailable)
{
    _isGameAvailable = IsGameAvailable;

    var result = _processor.BuyGame(_request);

    Assert.Equal(expectedPurchaseId, result.PurchaseId);
}

Oto nasza metoda testowa. Oczywiście nie mamy jeszcze pola PurchaseId więc dodajemy go przy użyciu kreatora.

a_35.png

public class GameBuyingResult : GameBuyingBase
{
    public GameBuyingResultCode StatusCode { get; set; }
    public int? PurchaseId { get; set; }
}

Pozostało na obsłużyć też tą magiczną liczbę 11, która symuluje na bazę danych. Warto na początku zwrócić uwagę, że na interfejs IGameBuyingRepository zwracał do tej pory void.

Teraz będziemy zwracać Id zakupu.

public interface IGameBuyingRepository
{
    int Save(GameBought gameBought);
}

Musimy powiedzieć MOCK-owi, aby zwrócił liczbę 11 przy wywołaniu metody Save.

[Theory]
[InlineData(11, true)]
[InlineData(null, false)]
public void ShouldReturnExpectedBoughtGameId(int? expectedPurchaseId, bool IsGameAvailable)
{
    _isGameAvailable = IsGameAvailable;

    if (IsGameAvailable)
    {
        _repositoryMock.Setup(x => x.Save(It.IsAny<GameBought>()))
            .Returns(11);
    }

    var result = _processor.BuyGame(_request);

    Assert.Equal(expectedPurchaseId, result.PurchaseId);
}

Teraz uruchomiamy test. Oczywiście nie przekazujemy teraz Id zakupy więc jeden z tych testów wykonuje się błędnie.

a_36.PNG

Do metody BuyGame pozostało nam dodać tylko jedną linijkę kodu.

public GameBuyingResult BuyGame(GameBuyingRequest request)
{
    if (request == null)
        throw new ArgumentNullException(nameof(request));


    GameBought gameBought = Create<GameBought>(request);
    gameBought.GameId = request.GameToBuy.Id;

    var result = Create<GameBuyingResult>(request);


    if (_gameRepository.IsGameAvailable(request.GameToBuy))
    {
        result.PurchaseId = _repository.Save(gameBought);
        result.StatusCode = GameBuyingResultCode.Success;
    }
    else
    {
        result.StatusCode = GameBuyingResultCode.GameIsNotAvailable;
    }

    return result;
}

Teraz test wyjdzie na zielono.