playground

.
Create

Making functions easier to unit test

It's very common to hear that automated tests improves an application's reliability and ables the developer to sleep without worry. Then you, as a very proactive programmer searches for how to make those tests and find that very easy... in the perfect scenario of the tutorial. When it comes to writing tests in real world, things can get very messy and that's the scenario that I want to figure out how to deal with. So here's the "mess" that I want to try writing tests: This is a code snippet in the Playground itself ```csharp filename="CommunityController.cs" public async Task<IActionResult> Create(CommunityFormModel model) { if (!ModelState.IsValid) return View(model); try { var creatorId = await _db.Usuarios.Where(u => u.UserName == User.Identity.Name).Select(u => (int?)u.Id).SingleOrDefaultAsync(); if (creatorId is null) { ModelState.AddModelError("", Localizer["ErrorMessage1"].Value); return View(model); } if (await NameExists(model.Nome)) { ModelState.AddModelError(nameof(model.Nome), Localizer["ErrorMessage2"].Value); return View(model); } var community= new Community { Name = model.Name, Description = model.Description?.Trim(), IdUserCreated = creatorId.Value, CreatedAt = DateTime.UtcNow }; _db.Communities.Add(community); await _db.SaveChangesAsync(); TempData["Success"] = Localizer["SuccessMessage"].Value; return RedirectToAction(nameof(Detalhes), new { id = community.Id }); } catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2601 or 2627 }) { ModelState.AddModelError(nameof(model.Nome), Localizer["ErrorMessage3."].Value); } catch (Exception ex) when (ex is SqlException or DbUpdateException) { DatabaseError(ex); } return View(model); } ``` This function is hard to unit test, mostly because of the direct database querys, making it difficult to mock behaviours. For example, if i try to test a community that fails in the name duplicated validation, the test would endup querying the real database, which can change the state of the db and the test would'nt be reliable since it depends on the db state. With that in mind, I'll refactor that function following the Single Responsibility Principle: the Controller must only be responsible to deal with the communication between the frontend and the service that will actually execute the designed action. Let's read line by line and understand in a higher level what the code is doing and judge what to do with it. ```csharp if (!ModelState.IsValid) return View(model); // Handles ModelState related to frontend, it is fine here. ``` ```csharp var creatorId = await _db.Usuarios.Where(u => u.UserName == User.Identity.Name).Select(u => (int?)u.Id).SingleOrDefaultAsync(); if (creatorId is null) { ModelState.AddModelError("", Localizer["ErrorMessage1"].Value); return View(model); } // Validates if the current User is a Guest or not. It is a business rule, should be in a Service class (for this project's pattern). ``` ```csharp if (await NameExists(model.Nome)) { ModelState.AddModelError(nameof(model.Nome), Localizer["ErrorMessage2"].Value); return View(model); } // Validates if the community name already exists. Another business rule, should be in a Service class. ``` ```csharp var community = new Community { Name = model.Name, Description = model.Description?.Trim(), IdUserCreated = creatorId.Value, CreatedAt = DateTime.UtcNow }; _db.Communities.Add(community); await _db.SaveChangesAsync(); // Creates the DataBase Entity and Saves it, should be in a Repository class. ``` ```csharp TempData["Success"] = Localizer["SuccessMessage"].Value; return RedirectToAction(nameof(Detalhes), new { id = community.Id }); // Handling more State and Redirection for frontend, it's fine here. ``` ```csharp catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2601 or 2627 }) { ModelState.AddModelError(nameof(model.Nome), Localizer["Já existe uma comunidade com esse nome."].Value); } catch (Exception ex) when (ex is SqlException or DbUpdateException) { DatabaseError(ex); } // Handling DataBase Exceptions, should be absolutely forbidden here, this handling should be in a Service / Repository class. ``` ```csharp return View(model); // Default Error return value. Nothing to comment here. ``` Now comes the interesting part: how to actually implement SOLID principles and break some code that does everything into many files. Since this is not a tutorial but rather just a document of my experience, I'm just going to show my approach. I particularly like to follow this flow: * Controller receives request. * Controller calls a Service class and returns the Service result. * The service by itself, controls business rules, validations and calls a Repository class for data persistence, if necessary. * Repository class saves the received data, that's all. This is a generic idea, and therefore, it has multiple ways to be implemented. This is the one I ended up choosing: Note: this refactor targets the viability of writing unit tests, ending up in implementing Clean Arch principles. In production, refactoring just to make code "cleaner" is difficult to be justified, since we'll risk adding new bugs to something we know it works. ```csharp filename="CommunityController.cs" public IActionResult Create(CreateCommunityDTO createCommunityDTO) { if (!ModelState.IsValid) return View(createCommunityDTO); // Controller only calls the Service class var cmd = _mapper.Map<SaveCommunityCommand>(createCommunityDTO); cmd.UsuarioNome = User.Identity!.Name; cmd.UsuarioId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "0"); var result = _comunidadeService.Save(cmd); // Then returns the Result if (!result.Success) { ModelState.AddModelError(nameof(createCommunityDTO.Name), Localizer[result.ErrorCode!].Value); return View(criarComunidadeDTO); } TempData["Success"] = Localizer["SuccessMessage"].Value; return RedirectToAction(nameof(Detalhes), new { id = resultado.Valor }); } ``` ```csharp filename="CommunityService.cs" public ServiceResult<int> Save(SaveCommunityCommand command) { if (command.UserId is null || command.UserId == 0) return ServiceResult<int>.Fail("FailUserNotRecognized"); if (!_userRepository.IsValidUserVerified(command.UserName ?? "")) return ServiceResult<int>.Fail("FailGuestUser"); if (!_communityRepository.IsValidNoDuplicatedName(command.Name)) return ServiceResult<int>.Fail("FailDupedCommunityName"); var id = _communityRepository.Save(new Domain.Community.Community { Name = command.Name, Description = command.Description?.Trim(), IdUserCreator = command.UserId.Value, CreatedAt = DateTime.UtcNow }); return ServiceResult<int>.Ok(id); } ``` Now with the created functions: communityRepository.Save(...) usuarioRepository.IsValidUserVerified(...) communityRepository.IsValidNoDuplicatedName(...) It's easy to write unit tests the way we learn in any youtube tutorial. Mock communityRepository and userRepository, setup their behaviour inside the test, execute CommunityService.Save(...) and assert the expected return value. Note: reading stuff about clean arch and unit test makes it look easy. This refactor took me about 4 hours, mostly arguing with AI on how to do it. As bad as it may feel to take this long to implement or refactor something this simple, I think it's the most appropriate way to learn concepts in programming, just send it, see how it works, break stuff, then fix stuff, regret some project decisions, deal with technical debt. And that's the hole point of creating this Playground.
0
0

Comments (0)

This post has no comments yet.