Doszedłem do wniosku, że skoro już uwierzytelnianie z wykorzystaniem Identity i OpenIddict mniej więcej mi już działa, to je wywalę. Postaram się stworzyć własne rozwiązanie, aby nauczyć się czegoś i żebym mógł rozwijać je w przyszłości, zgodnie z konkretnymi potrzebami. Poza tym sposób, który przedstawiałem wcześniej, nie do końca spełnił moje oczekiwania. Dlatego też chcę ogarnąć to samemu, a żeby ułatwić sobie pracę postaram się zastosować testy jednostkowe. W tym celu skorzystam z xUnit.

 

Czym są testy jednostkowe?

Ogólnie testy są nadzorcami naszej aplikacji. Dadzą nam one znać, gdy jakaś zmiana w kodzie sprawi, że zadzieje się coś czego nie przewidzieliśmy. Dzięki temu zabezpieczamy się przed sytuacją, że naprawianie jednego buga powoduje powstanie kolejnych. Przynajmniej na tyle, na ile nasze testy są w stanie to zrobić.

W szczególności testy jednostkowe (jak wskazuje na to ich nazwa) pozwalają nam testować konkretne rzeczy. Dlatego może ich się zrobić dużo - ale im więcej, tym mamy większą pewność, że nasza aplikacja działa tak, jak oczekujemy. Oczywiście nie można też przesadzić. Musimy odpowiednio nimi zarządzać, żeby czas spędzony nad ich pisaniem i potem czas ich wykonywania był optymalny względem złożoności problemu. 

Przede wszystkim będziemy testować zwracane wartości, rzucane wyjątki lub stan obiektu. Jeżeli mądrze rozplanujemy architekturę testów, to będziemy mogli uruchamiać tylko te, które dotyczą elementu, który obecnie modyfikujemy. Nie ma przecież sensu co chwilę wykonywać wszystkich (szczególnie, że to trwa), jeżeli edytujemy tylko małą część kodu, np. jedną usługę.

Jedną z metodyk tworzenia oprogramowania jest Test-driven development (w skrócie TDD), która polega na rozpoczęciu pracy od napisania testów i rozwijania aplikacji tak, aby testy zostały spełnione. Ma ona swoje plusy i minusy, ale na pewno znacznie obniża szansę na popełnienie błędu przez programistę.

 

Przygotowuję projekt do testowania

Istotne jest, że nasze testy będą oddzielnym projektem w solucji, na której pracujemy. Zaczynamy więc od stworzenia nowego projektu:

undefined

undefined

W nowym projekcie modyfikujemy plik project.json:

{
  "version": "1.0.0-*",
  "testRunner": "xunit",
  "dependencies": {
    "xunit": "2.2.0-beta2-build3300",
    "dotnet-test-xunit": "2.2.0-preview2-build1029",
    "Microsoft.EntityFrameworkCore.InMemory": "1.0.0"
  },
  "frameworks": {
    "netcoreapp1.0": {
      "dependencies": {
        "Microsoft.NETCore.App": {
          "type": "platform",
          "version": "1.0.0"
        }
      }
    }
  }
}

Istotnym elementem jest tu Microsoft.EntityFrameworkCore.InMemory, który pozwoli nam testować operacje na bazie danych, nie modyfikując tej, której używamy w aplikacji. Nie będzie trzeba się martwić o nadpisanie danych lub usuwanie śmieci po testach.

Pozostaje jeszcze dodać referencję do naszego głównego projektu:

undefined

I z listy wybieramy to, co chcemy testować.

Teraz będziemy musieli jeszcze skonfigurować dostęp do usługi bazy danych. Ja zrobiłem osobny plik Config.cs i będę umieszczał w nim elementy, które przydadzą mi się w testach.

using System;
using Microsoft.Extensions.DependencyInjection;
using SimplifyApp.Models;
using Microsoft.EntityFrameworkCore;

namespace SimplifyTests
{
    public class Config
    {
        public readonly IServiceProvider ServiceProvider;

        public Config() {
            var services = new ServiceCollection();

            services
                .AddEntityFrameworkInMemoryDatabase()
                .AddDbContext<SimplifyContext>(options => options.UseInMemoryDatabase());

            ServiceProvider = services.BuildServiceProvider();
        }
    } 
}

Tak wygląda w tej chwili zawartość klasy Config, która pozwala korzystać mi z tymczasowej bazy danych. Warto zwrócić uwagę na podobieństwo do klasy Startup z głównej aplikacji, jednakże tam obiekt services jest injectowany, a tutaj musimy go stworzyć i udostępnić sami. W ten sposób, gdy stworzymy instancję klasy Config w dowolnym teście, będziemy mieli dostęp do obiektu serviceProvider, a dzięki temu również do wszystkich usług, które zainicjowaliśmy.

 

Testowanie usługi uwierzytelniającej

Zgodnie ze wstępem, mam zamiar stworzyć usługę AuthService, która będzie pośredniczyć w procesie uwierzytelniania. Jednym z jej elementów będzie tworzenie nowego użytkownika w bazie. Mój model User jest na razie bardzo prosty ({ID, Email, Password}), dlatego sama usługa nie będzie miała trudnego zadania:

namespace SimplifyApp.Services
{
    public class AuthService {

        public void Register(RegisterViewModel model, SimplifyContext dbContext) {
            PasswordHasher<User> hasher = new PasswordHasher<User>();

            User newUser = new User();
            newUser.Email = model.Email;
            newUser.Password = hasher.HashPassword(newUser, model.Password);

            dbContext.Users.Add(newUser);
            dbContext.SaveChanges();
        }

    }
}

Przyjmujemy dwa parametry: model rejestracji odbierany przez kontroler i dbContext. Do zaszyfrowania hasła korzystam z klasy PasswordHasher dostępnej w Microsoft.AspNetCore.Identity. Mój model rejestracji to po prostu {Email, Password, ConfirmPassword}.

Okej, to teraz można napisać test do tej metody.

Tworzę nową klasę w SimplifyTests - AuthTests:

using System.Linq;
using SimplifyApp.Models;
using Xunit;
using SimplifyApp.Services;
using Microsoft.Extensions.DependencyInjection;
using SimplifyApp.Models.ViewModels;

namespace SimplifyTests {
    public class AuthTests {
        private readonly AuthService _authService;
        private readonly SimplifyContext _dbContext;

        public AuthTests() {
            _authService = new AuthService();
            Config config = new Config();
            _dbContext = config.ServiceProvider.GetRequiredService<SimplifyContext>();
        }
    }
}

Co tutaj się zadziało? Przede wszystkim zaimportowałem SimplifyApp.Services - gdzie znajduje się klasa AuthService, którą chcę testować. Stworzyłem instancję tej klasy wewnątrz testu, aby móc korzystać z jej metod. Stworzyłem też instancję klasy Config, aby mieć dostęp do InMemoryDatabase.

Pozostaje w takim razie napisać już sam test:

[Fact]
public void AbleToRegister()
{
    RegisterViewModel newModel = new RegisterViewModel {
        Email = "test@test.pl",
        Password = "admin12345",
        ConfirmPassword = "admin12345"
    };
    _authService.Register(newModel, _dbContext);
    var result = _dbContext.Users.First(user => user.Email == "test@test.pl");
    Assert.NotNull(result);
}

[Fact] oznacza, że dany test musi być spełniony dla dowolnego zestawu danych (metoda AbleToRegister nie przyjmuje parametrów). Jest jeszcze [Theory], gdzie możemy podać konkretny zbiór danych.

Zaczynam od stworzenia RegisterViewModel, czyli w skrócie - symulowania wpisania danych przez użytkownika. Zaraz potem używam metody z klasy AuthService, podając jej stworzony ViewModel i testowy dbContext. Po wykonaniu tej operacji sprawdzam, czy w bazie istnieje użytkownik, którego stworzyłem przy pomocy AuthService. Można oczywiście rozbudować ten test, sprawdzając np. czy hasło zostało poprawnie zahashowane - ale to pośrednio sprawdzi test AbleToLogin.

Czas na skompilowanie wszystkiego i odpalenie testów. W Visual Studio jest specjalna zakładka, która ułatwia pracę z testami:

undefined

undefined

(AbleToLogin to mój testowy test, nie testuje niczego)

Po odpaleniu Run All, jeżeli nic po drodze się nie wywali, test powinien przejść:

undefined

 

Co dalej?

Mój pomysł to stworzenie chain-testów dotyczących uwierzytelniania: tworzenie konta -> zalogowanie -> stworzenie sesji -> przesłanie informacji o sesji -> wylogowanie -> zniszczenie sesji -> próba skorzystania ze zniszczonej sesji. Schemat pracy jest taki: przemyślenie podstaw całego procesu uwierzytelniania, stworzenie testów do każdego etapu i dopiero wtedy praca na właściwym kodzie, czyli rozbudowywanie AuthService, aby testy przeszły.

Postaram się za to zabrać w niedługim czasie. Dzięki za odwiedziny!