Słuchajcie mnie wszyscy bo jakoby ja, ten co wiedzę niesie, powołanie poczułem i głosił będę. Jak mówi jedna ze świetnych zasadach testów jednostkowych, każda klasa i metoda powinna być testowana w całkowitej izolacji. W szczególności, co pamiętać trzeba, bez wywoływania oprogramowania zewnętrznego, jak na przykład baz danych. Żarty żartami, ale zasada jest w 100% poważna. No i nie powstała bez powodu. No dobra, ale jak mamy ją zastosować, kiedy w naszej aplikacji wykorzystujemy bibliotekę .NET Core Identity? Przecież nie ma łatwego sposobu, by ją zmockować za pomocą prostych i szeroko znanych bibliotek mock-ujących. Dlatego, w dzisiejszym artykule, chce wam pokazać satysfakcjonujące rozwiązanie.
Opis środowiska
Cały test będzie oparty na świeżo wygenerowanej aplikacji .NET Core MVC z ustawioną opcją "auth" na Individual. Tworzymy ją za pomocą komendy:
$ dotnet new mvc --auth=Individual
Dodatkowo potrzebujemy projektu z testami. Dla przypomnienia - wygenerowanie pustego projektu xUnit wygląda następująco:
$ dotnet new xunit
Proste, łatwe i przyjemne w użyciu.
Brak interfejsów powodem zmartwień
Teraz zaczynamy jazdę, więc zaopatrzcie się w kakałko. Już? Na pewno? Ok, skupmy się teraz, przez chwilę, na aplikacji MVC. Na samyw wstępie swojego istnienia zawiera całkiem spore kawałki kodu. Oprócz standardowej konfiguracji samego .NET Core Identity otrzymujemy kilka modeli, serwis i parę dodatkowych kontrolerów. Zakładając, że chciałbym pokryć testami jednostkowymi kontroler "AccountController", muszę w jakiś sposób rozwiązać jego zależności:
public AccountController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IEmailSender emailSender,
ILogger<AccountController> logger)
{
_userManager = userManager;
_signInManager = signInManager;
_emailSender = emailSender;
_logger = logger;
}
O ile zmockowanie takich klas jak "IEmailService" czy "ILogger" nie powinno stanowić większego problemu (w końcu to tylko interfejsy), to już troszkę gorzej z "UserManager" i "SignInManager". Obie wymagają pełnej implementacji klas. Dlatego biblioteka, której ja używam, miałaby sporę kłopoty z nadpisaniem publicznych metod ich obu.
Pójdźmy o krok dalej. W metodzie "Login" tego kontrolera jest używana klasa SignInManager. Pełna metoda wygląda następująco:
public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
if (ModelState.IsValid)
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
_logger.LogInformation("User logged in.");
return RedirectToLocal(returnUrl);
}
if (result.RequiresTwoFactor)
{
return RedirectToAction(nameof(LoginWith2fa), new { returnUrl, model.RememberMe });
}
if (result.IsLockedOut)
{
_logger.LogWarning("User account locked out.");
return RedirectToAction(nameof(Lockout));
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
}
// If we got this far, something failed, redisplay form
return View(model);
}
Może wydawać się to trudne, ale przedstawię wam jak przetestować ścieżkę pozytywnego uwierzytelnienia nie zmieniając nawet linijki z powyższego kodu.
Tworzenie mock-ów UserManager i SignInManager
Skoro znamy już główna przyczynę trudności mockowania .NET Core Identity do testów jednostkowych, możemy przejść do mojego rozwiązania tego problemu. Będziemy potrzebowali dwie fake klasy, które będziemy mogli "wstrzyknąć" do kontrolera w zastępstwie tych zaimplementowanych przez .NET Core Identity. To brzmi banalnie, prawda? Spójrzmy na konstruktor klasy UserManager
public UserManager (Microsoft.AspNetCore.Identity.IUserStore<TUser> store, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Identity.IdentityOptions> optionsAccessor, Microsoft.AspNetCore.Identity.IPasswordHasher<TUser> passwordHasher, System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Identity.IUserValidator<TUser>> userValidators, System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Identity.IPasswordValidator<TUser>> passwordValidators, Microsoft.AspNetCore.Identity.ILookupNormalizer keyNormalizer, Microsoft.AspNetCore.Identity.IdentityErrorDescriber errors, IServiceProvider services, Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Identity.UserManager<TUser>> logger);
Klasa UserManager posiada 9 zależności!! Dodatkowo klasa SignInManager ze swoimi 6 zależnościami nie wygląda lepiej.
public SignInManager (Microsoft.AspNetCore.Identity.UserManager<TUser> userManager, Microsoft.AspNetCore.Http.IHttpContextAccessor contextAccessor, Microsoft.AspNetCore.Identity.IUserClaimsPrincipalFactory<TUser> claimsFactory, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Identity.IdentityOptions> optionsAccessor, Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Identity.SignInManager<TUser>> logger, Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemes);
Aż chce się tutaj powiedzieć.... No ktoś nie czytał Wujka Bob-a...
Narzekanie na implementacje klas nie doprowadzi nas do końca, wiec czas spiąć się w sobie i śmiało wziąć się do roboty. Wykorzystując podstawową bibliotekę do mock-owania danych w testach jednostkowych Moq, zaimplementowanie fejkowych klas nie powinno trwać zbyt długo. Na całe szczęście, na tym etapie pojawia się wykorzystywanie interfejsów zamiast pełnych implementacji. Wyglądają to następująco:
public class FakeUserManager : UserManager<ApplicationUser>
{
public FakeUserManager()
: base(new Mock<IUserStore<ApplicationUser>>().Object,
new Mock<IOptions<IdentityOptions>>().Object,
new Mock<IPasswordHasher<ApplicationUser>>().Object,
new IUserValidator<ApplicationUser>[0],
new IPasswordValidator<ApplicationUser>[0],
new Mock<ILookupNormalizer>().Object,
new Mock<IdentityErrorDescriber>().Object,
new Mock<IServiceProvider>().Object,
new Mock<ILogger<UserManager<ApplicationUser>>>().Object)
{
}
}
public class FakeSignInManager : SignInManager<ApplicationUser>
{
public FakeSignInManager()
: base(new Mock<FakeUserManager>().Object,
new HttpContextAccessor(),
new Mock<IUserClaimsPrincipalFactory<ApplicationUser>>().Object,
new Mock<IOptions<IdentityOptions>>().Object,
new Mock<ILogger<SignInManager<ApplicationUser>>>().Object,
new Mock<IAuthenticationSchemeProvider>().Object)
{ }
}
Jest to minimalna ilość kodu, jaką znalazłem, aby móc bezproblemowo podmienić instancję w kontrolerze.
Wzorzec budowniczy do łatwiejszego budowania obiektów
Osobiście, w wielu przypadkach, zamykam takie często wykorzystywane klasy we wzorzec Budowniczy. Pozwala to na łatwe i przejrzyste zarządzanie stanem obiektu podczas pojedynczego testu. Przykładowa implementacja tego wzorca wyglądałaby następująco:
public class FakeUserManagerBuilder
{
private Mock<FakeUserManager> _mock = new Mock<FakeUserManager>();
public FakeUserManagerBuilder With(Action<Mock<FakeUserManager>> mock)
{
mock(_mock);
return this;
}
public Mock<FakeUserManager> Build()
{
return _mock;
}
}
public class FakeUserManagerBuilder
{
private Mock<FakeUserManager> _mock = new Mock<FakeUserManager>();
public FakeUserManagerBuilder With(Action<Mock<FakeUserManager>> mock)
{
mock(_mock);
return this;
}
public Mock<FakeUserManager> Build()
{
return _mock;
}
}
Oczywiście, za pomocą generycznych klas. można by to skrócić do pojedynczego FakeGenericBuilder. To jednak materiał na inny artykuł, nie ma co mieszać w głowach.
Wracając do głównego wątku, proszę byście zwrócili uwagę na wykorzystanie metody "With". Pozwala nam, za pomocą delegatów, sterować stanem obiektów prosto z testu jednostkowego.
Napisanie testu - zwięczenie przygody
W ten sposób konfigurujemy całe środowisko do testowania wspomnianych przypadków. Zobaczmy przykładowy test kontrolera sprawdzającego, czy po udanym zalogowaniu użytkownik zostanie przekierowany dalej.
[Fact]
public async void Test()
{
var fakeUserManager = new FakeUserManagerBuilder()
.Build();
var fakeSignInManager = new FakeSignInManagerBuilder()
.With(x => x.Setup(sm => sm.PasswordSignInAsync(It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<bool>(),
It.IsAny<bool>()))
.ReturnsAsync(SignInResult.Success))
.Build();
var fakeEmailSender = new Mock<IEmailSender>();
var fakeLogger = new Mock<ILogger<AccountController>>();
var mockUrlHelper = new Mock<IUrlHelper>(MockBehavior.Strict);
mockUrlHelper
.Setup(x => x.IsLocalUrl(It.IsAny<string>()))
.Returns(true)
.Verifiable();
var controller = new AccountController(
fakeUserManager.Object,
fakeSignInManager.Object,
fakeEmailSender.Object,
fakeLogger.Object);
controller.Url = mockUrlHelper.Object;
var result = await controller.Login(new LoginViewModel(), "testPath");
Assert.IsType<RedirectResult>(result);
}
Nie chcąc przedłużać pokazuje już pełna implementacje kodu. Widać dokładnie, że sekcja "arrange" jest dość rozbudowana. Jak wyglądają kolejne kroki? Najpierw tworzymy instancję naszych klas fejkowych za pomocą wzorca budowniczego. Jak na dłoni można zauważyć, że ,za pomocą wyrażenia lambda, w prosty sposób zamockowaliśmy aby nasz fejkowy SignInManager tak, aby zawsze zwracał informację o poprawnym zalogowaniu. Następnie przechodzimy przez bardzo proste mock-i IEmailService i ILogger. Są to całkowicie podstawowe mock-i. Sekcję arrange kończymy za pomocą mock-a IUrlHelper-a i stworzeniem prawdziwej instancji klasy AccountController. Mock UrlHelper jest potrzebny, ponieważ AccountController wykorzystuje go do sprawdzenia czy "redirectUrl" jest adresem lokalnym.
Przechodząc do sekcji "act" nie pozostaje nam nic innego jak tylko wywołać metodę "Login" na świeżo stworzonej instancji kontrolera. Na koniec wykonujemy Assert-a, aby sprawdzić czy wartość zwrócona przez akcję kontrolera jest prawidłowa i... voila! Nasz test wykorzystujący mockowanie .NET Core Identity jest gotowy.
Dzięki za przeczytanie artykułu.
Do Następnego!
Cześć :)