Testowanie CRUDa

0

Mam taki kod:

var review = await _db.Reviews.FindAsync(request.ProductId, request.CustomerId);

if (review == null)
    return new Result(ErrorType.NotValid, "Recenzja nie istnieje.");

_db.Reviews.Remove(review);
await _db.SaveChangesAsync(cancellationToken);

return Result.SuccessfulResult;

Czy warto go testować? Na pierwszy rzut oka widać, że będzie działał. Testy jednostkowe do niego kosztują 70 linijek, podczas gdy cała klasa z tym kodem ma ich 35. Czy testy nie powinno się pisać tylko dla jakiejś skomplikowanej logiki? W mojej aplikacji mam praktycznie same CRUDy. Co ja mam w niej testować?

2

Też mam same CRUDy ale dzięki testom czasami wyłapuje jakieś brzegowe przypadki złego działania.

1

Testy jednostkowe do niego kosztują 70 linijek

Testy jednostkowe na zasadzie zrób mocki do wszystkiego i niech się dzieje wola nieba? :-P

0
Patryk27 napisał(a):

Testy jednostkowe do niego kosztują 70 linijek

Testy jednostkowe na zasadzie zrób mocki do wszystkiego i niech się dzieje wola nieba? :-P

Jeśli to jest EF Core to pewnie EFInMemory i zapełnia danymi i testuje poprawne zachowanie

0

@Patryk27: Wyglądają tak:

using System;
using System.Threading;
using System.Threading.Tasks;
using MyStore.Application.Interfaces.Security;
using MyStore.Application.Products.CommandHandlers;
using MyStore.Application.Products.Commands;
using MyStore.Common.Types.Results;
using MyStore.Database;
using MyStore.Model;
using MyStore.Tests.Infrastructure;
using Shouldly;
using Xunit;

namespace MyStore.Tests.Products.CommandHandlers
{
    [Collection(nameof(ApplicationCollection))]
    public class DeleteReviewHandlerTests : IDisposable
    {
        private readonly ApplicationDbContext _db;
        private readonly IAuthContext _auth;

        public DeleteReviewHandlerTests(ApplicationFixture fixture)
        {
            _db = fixture.Db;
            _auth = fixture.Auth;
        }
        
        [Fact]
        public async Task Should_Not_Delete_Review_When_Review_Does_Not_Exist()
        {
            var handler = new DeleteReviewHandler(_db, _auth);

            var command = new DeleteReview(100, _auth.GetCurrentCustomerId());

            var result = await handler.Handle(command, CancellationToken.None);

            result.IsSuccessful.ShouldBe(false);
            result.Error.Type.ShouldBe(ErrorType.NotValid);
        }

        [Fact]
        public async Task Should_Delete_Review()
        {
            var review = new Review
            {
                ProductId = 1,
                AuthorId = _auth.GetCurrentCustomerId(),
                Stars = 5,
                Content = "Test Content"
            };

            _db.Reviews.Add(review);
            _db.SaveChanges();

            var handler = new DeleteReviewHandler(_db, _auth);

            var command = new DeleteReview(review.ProductId, review.AuthorId);

            var result = await handler.Handle(command, CancellationToken.None);

            result.IsSuccessful.ShouldBe(true);
        }

        public void Dispose()
        {
            _db.Reviews.RemoveRange();
            _db.SaveChanges();
        }
    }
}
1

Ja bym sprawdzał czy na pewno obiekt został usunięty z bazy. Bo jak pominiesz w kodzie savechangs to test też przejdzie :)

0

@szydlak: Wiem, miałem to dziś dopisać. ;) W ogóle testy i kod są trochę rozsynchronizowane ze sobą, więc nie analizujcie, czy wszystko ma sens, chodzi jedynie o ideę. ;)

1

to bardziej integracyjne niż jednostkowe :P chyba, że zrobisz abstakcję na usuwanie, pobieranie i dodawanie ;) :D

0

Czytam sobie ostatnio o testach integracyjnych i zastanawiam się, czy nie lepiej byłoby ich użyć w zamian "jednostkowych" (takich jak wyżej). Żeby było jasne: mam na myśli coś takiego: https://docs.microsoft.com/pl-pl/aspnet/core/test/integration-tests?view=aspnetcore-2.2
Aby zwiększyć wydajność mógłbym podpiąć SQLite'a in-memory i skorzystać z collection fixtures. Miałbym np taki kod:

[Fact]
public async Task Should_Register_User()
{
    var command = new Register("User1", "[email protected]", "Secret123");
            
    var response = await _client.PostAsync("/api/auth/register", GetPayload(command));
            
    response.EnsureSuccessStatusCode();
}

Różnica w wydajności nie powinna być duża w takim przypadku. @Aventus pisał kiedyś, że jego zdaniem testy jednostkowe należy ograniczyć do minimum i stosować je tylko tam, gdzie faktycznie przynoszą jakieś korzyści. Czy testy integracyjne wyglądają wówczas tak, że testuje się każdą ścieżkę egzekucji żadania osobno? Testowanie jedynie happy path to chyba za mało.

0

Gwoli ścisłości, ja opowiadałem się za czymś pośrednim między testem jednostkowym a integracyjnym. W takim przypadku test uderza w endpoint (za pomocą hosta w pamięci opisanego w Twoim linku), natomiast wszelkie inne zależności których w konkretnym teście nie chcę sprawdzać podmieniam- czy to za pomocą biblioteki mockującej czy "manualnie" dostarczając testową implementację intefejsu.

0

@Aventus: Moglbys podac jakis przyklad? Nie moge sobie tego wyobrazic :(

0

Powiedzmy że mam kontroler BookingsController który wystawia metodę Create (HTTP POST). Ten kontroler przyjmuje interfejs IBookingsRepository. W testach opisanych przeze mnie test jednostkowy uruchomi host w pamięci, czyli będziesz używał klienta HTTP do wykonania requesta do tego API. Natomiast podstawisz testową implementację IBookingsRepository, a więc aplikacja nie będzie się komunikowała z prawdziwą bazą danych. Jeśli to nadal nie ma sensu to daj znać.

0

Hmm, czyli po prostu uzyjesz innej od produkcyjnej bazy danych? Czy moze chodzi o napisanie dodatkowej implementacji interfejsu repozytorium wylacznie na potrzeby testow?

0

a po co, jeżeli tak jak @szydlak wspomniał jest InMemoryDb (EFC)?

0
nobody01 napisał(a):

Hmm, czyli po prostu uzyjesz innej od produkcyjnej bazy danych? Czy moze chodzi o napisanie dodatkowej implementacji interfejsu repozytorium wylacznie na potrzeby testow?

Użyje czegoś w pamięci. A to czy będzie to EntityFramework w pamięci, moja własna implementacja wrapująca zwykłą listę obiektów czy też coś innego to zależy od konkretnego projektu i tego jaka baza danych jest używana. Mam nadzieję że to też odpowiada na pytanie @WeiXiao.

0

@Aventus Chciałbym się jeszcze upewnić, że rozumiem, o co chodzi. Powiedzmy, że po zarejestrowaniu użytkownika chcę wysłać email. Mam interfejs IEmailService. W projekcie z testami tworzę więc implementację wyłącznie na potrzeby testów:

public class FakeEmailService : IEmailService
    {
        private readonly ILogger<FakeEmailService> _logger;

        public FakeEmailService(ILogger<FakeEmailService> logger)
        {
            _logger = logger;
        }

        public Task<EmailResult> SendAsync(EmailMessage message)
        {
            var payload = JsonConvert.SerializeObject(message);

            _logger.LogInformation($"Sending email. Details: {payload}");

            return Task.FromResult(EmailResult.SuccessfulResult);
        }
    }

Tworzę sobie jakieś CustomWebApplicationFactory i w ConfigureWebHost dodaję

builder.ConfigureTestServices(services =>
            {
                services.AddTransient<IEmailService, FakeEmailService>();
            });

I teraz takie pytanie: czy tego rodzaju testów warto używać do testowania wszystkich możliwych ścieżek (Ok, NotFound, BadRequest)? Domyślam się, że do testowania walidatora nie będę wysyłał kilkunastu żądań, tylko jedno, aby mieć pewność, że zostaje odpalony.

1

I teraz takie pytanie: czy tego rodzaju testów warto używać do testowania wszystkich możliwych ścieżek (Ok, NotFound, BadRequest)? Domyślam się, że do testowania walidatora nie będę wysyłał kilkunastu żądań, tylko jedno, aby mieć pewność, że zostaje odpalony.

Ja testuję wszystkie możliwe ścieżki. Natomiast jest Twój walidator da się przetestować jako jednostkę to właśnie dobry kandydat na test jednostkowy- sprawdzasz wszystkie możliwe opcje bez uruchamiania hosta w pamięci i wysyłania requestów HTTP.

0

@Aventus: A czy stawianie aplikacji i bazy in-memory przy każdym teście nie powoduje problemów wydajnościowych? Z drugiej strony, jeśli każdy test operuje na własnej instancji aplikacji, to można takie testy odpalać równolegle. Na StackOverflow piszą, że można też otaczać każdy test transakcją i wykonywać automatycznie rollbacki po zakończeniu, ale to chyba kombinowanie pod górę.

1

A czy stawianie aplikacji i bazy in-memory przy każdym teście nie powoduje problemów wydajnościowych?

Naturalnie takie testy nie są tak szybkie jak testy jednostkowe, ale coś za coś. Ja wolę mieć lepszy obraz tego co jest testowane, czyli cały workflow a nie wyrwane z kontekstu jednostki. Myślę że grunt to znaleźć odpowiedni balans.

1

wydajność w testach? testy odpalają ci się automatycznie po napisaniu linijki kodu czy o co chodzi? :P

Po tym jak działający na testach model danych wywalił mi się przy prawdziwej bazie, to część testów leci na prawdziwą bazę - lokalnie lub na appveyorze. Trwa to z 1-2min, ale przynajmniej wiem, że działa.

public DatabaseModelVerification()
{
	var config = new ConfigurationBuilder()
					 .AddJsonFile("appsettings.json")
					 .Build();

	try
	{
		SetupRealDb(config.GetConnectionString("AppVeyor"));
	}
	catch
	{
		SetupRealDb(config.GetConnectionString("TestDb"));
	}
}
0

No ok, ale czy te testy dzialaja na efc in memory provider, czy moze na sqlite in memory? Ktos tu kiedys pisal, ze na tym pierwszym mozna trafic na false positive.

2

@Aventus @WeiXiao Trafiłem przed chwilą na mega wartościowy post o transakcyjnych testach integracyjnych: https://nance.io/leveling-up-your-dotnet-testing-transactional-integration-testing-in-asp-net-core/ Dzięki temu można wydajnie wykonywać testy na MSSQL zamiast na InMemoryDb. Autor jest inżynierem w Amazonie, więc chyba wie, co pisze. :P

3

W skrócie: tak warto testować CRUDa, zdecydowanie nie warto używać EF in memory, na którym nie działa sporo rzeczy które działają na normalnej bazie danych, SQLite in memory jest zdecydowanie mniej problematyczne.

I preferuje takie podejście:

  • integracyjne, testujemy wystawiony endpoint: host w pamięci + baza w pamięci -> testujemy szczęśliwą ścieżkę + czy uwierzytelnianie działa
  • jednostkowo z bazą w pamięci, testujemy serwisy aplikacyjne czy też kontrolery czy gdzie tam orkiestrujemy nasz use casy na wszystkie wyjątkowe ścieżki

Uwagi:

  • mają powyższe testy można całe api napisać ani razu go nie odpalając, odchodzi potrzeba używana narzędzi pokroju postmana
  • używanie bazy w pamięci jest niesamowicie wygodne
  • ważne żeby nie współdzielić dbcontextu pomiędzy którymś A z Arrange, Act, Asser

Przykłady:

[TestClass]
public class Questions_HappyPath : BaseFixture
{
    private const string EndpointName = "Questions";
    private HttpClient client;


    [TestInitialize]
    public void TestInitialize()
    {
        factory = new ApiFactory(ApiFactory.DatabaseType.SQLiteInMemory);
        client = factory.CreateClient(ValidToken);
        SeedDatabase(factory);           
    }

  
    [TestMethod]
    [DataRow(1)]
    [DataRow(2)]
    [DataRow(3)]
    public async Task ReadQuestionWithAnswers(long questionId)
    {
        var response = await client.GetAsync($"{EndpointName}/{questionId}/");
      
        var actualQuestion = response.GetContent<QuestionDTO>().Value;
        var context = factory.GetContext<TestCreationDbContext>();
        var expectedQuestion = context.Questions.Include(x => x.Answers).FirstOrDefault(x => x.QuestionId == questionId);

        AssertExt.AreEquivalent(expectedQuestion, actualQuestion);
    }

    [TestMethod]
    public async Task CreateQuestionWithAnswers()
    {
        var command = new CreateQuestion()
        {
            CatalogId = 1,
            Content = "Who is your dady?",
            Answers = new List<CreateAnswer>()
            {
                new CreateAnswer() { Content = "Adam", IsCorrect = true },
                new CreateAnswer() { Content = "Peter", IsCorrect = false}
            }
        };

        var response = await client.PostAsync(EndpointName, command);

        var createdId = response.GetContent<long>().Value;
        var context = factory.GetContext<TestCreationDbContext>();
        var actualQuestion = context.Questions.Include(x => x.Answers).FirstOrDefault(x => x.QuestionId == createdId);

        AssertExt.AreEquivalent(command, actualQuestion);
    } 
}
[TestClass]
public class QuestionsServiceTests : BaseFixture
{
    private TestCreationDbContext testCreationDbContext;
    private QuestionsService serviceUnderTest;

    private protected override DatabaseType GetDatabaseType()
    {
        return DatabaseType.SQLiteInMemory;
    }

    [TestInitialize]
    public void TestInitialize()
    {           
        testCreationDbContext = CreateTestCreationDbContext();
        var uow = TestUtils.CreateTestCreationUoW(testCreationDbContext);
        serviceUnderTest = new QuestionsService(new QuestionReader(CreateReadOnlyTestCreationDbContext()), uow);
    }
    [TestCleanup]
    public void TestCleanup()
    {
        testCreationDbContext.Dispose();
    }
  

    [TestMethod]
    [DataRow(ValidQuestionId, ResultStatus.Ok)]       
    [DataRow(NotExisitngQuestionId, ResultStatus.NotFound)]
    [DataRow(DeletedQuestionId, ResultStatus.NotFound)]
    [DataRow(OtherOwnerQuestionId, ResultStatus.Unauthorized)]
    public void ReadQuestionWithAnswers(long questionId, ResultStatus expectedResult)
    {
        Result result = serviceUnderTest.ReadQuestionWithAnswers(OwnerId, questionId);
        Assert.AreEqual(expectedResult, result.Status);
    }

    [TestMethod]      
    [DataRow(ValidQuestionsCatalogId, ResultStatus.Ok)]
    [DataRow(DeletedQuestionsCatalogId, ResultStatus.Error)]
    [DataRow(ValidTestsCatalogId, ResultStatus.Error)]
    [DataRow(NotExisitngQuestionsCatalogId, ResultStatus.Error)]
    [DataRow(OtherOwnerQuestionsCatalogId, ResultStatus.Unauthorized)]
    public void CreateQuestionWithAnswers(long catalogId, ResultStatus expectedResult)
    {
        var command = new CreateQuestion()
        {
            Content = "Dani Carvajal",
            CatalogId = catalogId,
        };
        Result result = serviceUnderTest.CreateQuestionWithAnswers(OwnerId, command);
        Assert.AreEqual(expectedResult, result.Status);
    }
}

1 użytkowników online, w tym zalogowanych: 0, gości: 1