ControllerCzęść NR.5 Kiedy budujesz aplikację z interfejsem użytkownika, możesz wyraźnie odseparować definicję czystego interfejsu np. HTML od logiki takiego interfejsu np. JavaScript.
Logika interfejsu użytkownika definiuje na przykład, co się stanie, gdy użytkownik kliknie przycisk
Moje doświadczenie programistyczne, które ma już 9 lat mówi mi, że w TDD najlepiej się testuje logikę interfejsu użytkownika. Co prawda możesz napisać testy przy użyciu Selenium i testować czy odpowiedni DIV w HTML jest w odpowiednim kolorze, ale taki poziom testów błaga też o pytanie ich sensu.
Najlepiej testuje się logikę. Co więcej, warto zaznaczyć, że logikę można testować tylko wtedy gdy mamy jasny podział pomiędzy logiką a definicją interfejsu użytkownika.
Wcześniej użyłem przykładu HTML, JavaScript, ale wiesz mi, nie zawsze taki podział jest łatwy.
Sam framework musi być dostosowany i odpowiednio przygotowany inaczej nie napiszesz żadnych testów. Oto przykład domyślnej architektury aplikacji WPF / WinUi
W aplikacji WPF masz definicję wyglądu aplikacji w postaci kodu XAML. XAML to język znacznikowy jak HTML więc tego nie testujemy. Kod logiki natomiast znajduje się w pliku code-behind np. Main.cs
Ten kod częściowo też jest definicją interfejsu użytkownika. Ten kod jest mocno powiązany z plikiem XAML i jak już wiesz, silne powiązania udaremniają pisanie testów jednostkowych.
Nie mamy więc separacji. Zapomnij więc o testowaniu warstwy prezentacji. Jedyne co może Cię uratować to wzorzec MVVM, który oddzieli logikę UI od pliku code-behind, jak najbardziej jest to możliwe.
Tworzysz wtedy ViewModel i jego możesz testować. Do niego też możesz dodawać bez problemu mechanizm wstrzykiwania zależności.
A jak to jest z ASP.NET?
Kiedyś (czytaj przed rokiem 2010) aplikację w ASP.NET pisało się w frameworku WebForms. Były taki pliki .aspx, które definiowały wygląd aplikacji i był on mieszanką HTML i kontrolerek napisanych w C#.
Każda strona .aspx miała swój plik code-behind aspx.cs. Czyli tak znowu było silne powiązanie logiki z definicją wyglądu.
ASP.NET Web Forms stosował antywzorzec Smart UI. Zrobiłem o tym kiedyś wpis: https://cezarywalenciuk.pl/blog/programing/c-smart-ui-antywzorzec-aspnet
Wyglądało to tak:
Przed wzorcem i frameworkiem MVC, który istnieje do dziś w ASP.NET CORE, próbowało się rozwiązać ten problem, pisząc swój własny kod wzorcem Model-View-Presenter (tak to miało jeszcze inną nazwę)
Ja zaczynałem swoją przygodę na studiach w 2008 roku, więc jeszcze ASP.NET Web Forms pamiętam i nawet z nim pracowałem do roku 2014. Teraz ASP.NET Web Forms jest zmorą starych projektów legacy, które jak się domyślasz, nie mają żadnych testów.
No cóż, jak sam się domyślasz, jeśli nie można było pisać testów, wtedy przez te frameworki to nikt nie chciał wdrażać procesu TDD do firmy. Na szczęście ty żyjesz w nim w świecie.
W ASP.NET CORE i każdy następnym ASP.NET-cię będziesz miał zawsze oddzieloną logikę UI. Mogą to być Kontrolery, mogą to też być PageModel-e.
Możesz też testować same Modele w MVC, by sprawdzić, czy np. poprawnie sprawdzają się pola przy weryfikacji formularzu. A czym są te Modele?
Są one pośrednikami danych pomiędzy logiką UI i definicją UI i je też będziemy testować.
Piszemy więc test
W poprzednich wpisach zrobiliśmy testy do warstwy biznesowej i warstwy dostępu do danych.
Zbliżamy się do końca pora na warstwę prezentacji, gdzie będzie żył ASP.NET CORE.
Jak widzisz każda, warstwa ma swój własny test jednostkowy. Zazwyczaj tworzyłem sam test jednostkowy, a później projekt aplikacji. Tym razem zrobię inaczej i stworze równocześnie i projekt testowy i projekt WEB. Tworzymy więc projekt testu XUnit o nazwie GameShop.Web.Test
Zmieniam mu później przestrzeń nazwy na GameShop.Web
Teraz tworzę aplikację ASP.NET CORE.
Nazywam ten projekt "GameShop.Web". Później wybieram pusty szablon, bo lubię ci pokazywać, jak tworzę aplikację ASP.NET Core od zera.
Zmieniam kod klasy Startup.cs na:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
Do aplikacji ASP.NET CORE dodałem kontroler i widok dopasowany do tego kontrolera
Mój kontroler na razie ma dwie metody. Pierwsza z nich to Index, która wywoła widok Index.cshtml, a druga metoda to obsłuży mechanizm zakupu gry.
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
public IActionResult BuyGame()
{
return View();
}
}
Aplikacja jest gotowa.Teraz można napisać pierwszy test. Oczywiście jednak nie napiszemy testu bez wymagań.
Jakie mamy wymagania:
- Chcemy mieć w kontrolerze metodę, która wywoła nasz procesor zakupu danej gry
- Jak pamiętasz w poprzednich wpisach, sprawdzałem logikę samego procesora zakupu gry, a w innej warstwie testowałem czy repozytorium zapiszę zamówienie naszej gry. Dziś w warstwie prezentacji musimy sprawdzić, czy model przesłany do kontrolera odpowiednio wyślę się do procesora.
- Dodatkowo chcemy sprawdzić konwersje modelu zakupionej gry do zapytania o zakup gry.Tylko poprawne dane klienta wykonają poprawnie działanie zakupu gry.
- Chcemy zablokować możliwość zakupu, gdy model nie jest poprawny
- Chcemy zwrócić model błędu, gdy gra nie jest dostępna już do zakupu.
- Użytkownik później ten model zobaczy na stronie HTML.
- Chcemy sprawdzić, czy nasz kontroler zwraca poprawny IActionResult w metodzie BuyGame.
- Jeżeli zakup wykonał się poprawnie, to chcemy odesłać użytkownika do odpowiedniej strony. Gdy zakup się, nie udał, to chcemy go przekierować do strony z błędem.
Zaczynamy od pierwszego wymagania.
public class HomeControllerTests
{
[Fact]
public void ShouldCallGameBuyingRequestProcessor()
{
}
}
W projekcie WEB utworzyłem model GameBuyModel. Zgodnie z tradycją MVC utworzyłem folder Model i do niego umieściłem klasę.
Model oczywiście będzie nam już potrzebny do innych testów. Na razie będę testował sam kontroler Home, ale modele mi są potrzebne. GameBuyModel wygląda tak:
public class BuyGameModel
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public GameModel Game { get; set; }
}
Zauważ, że nie ma on pola Date.
Zawiera on kolejny model w sobie, a jest nim GameModel.
public class GameModel
{
public int Id { get; set; }
public string Name { get; set; }
}
Dlaczego to robimy? Często definicję obiektów wysłanych do aplikacji WEB różni się od klas, które podróżują po warstwie logiki biznesowej. Nie chcesz dodawać nowego pola do klas w warstwie logiki biznesowej, gdy chodzi o jakąś flagę w interfejsie użytkownika.
Modele mogą wyglądać podobnie do klas i nawet podobnie się nazywać, ale spełniają różne role.
Będę szczery mogłem to zignorować i operować na klasach które mamy w GameShop.Core, ale chciałem Ci pokazać, dlaczego to robimy. Mamy też dobry powód do pisania testów, bo często błędy w aplikacjach wynikają ze złej translacji jednego obiektu w drugi.
Dodajemy do projektu GameShop.Web.Test referencję do projektu GameShop.Web, gdyż nie zrobiliśmy tego wcześniej.
Na razie nasz test wygląda tak. Musimy teraz zmienić sam kontroler, a zanim to zrobimy musimy zmienić kod naszego GameBuyingRequestProcessor
[Fact]
public void ShouldCallGameBuyingRequestProcessor()
{
var controller = new HomeController();
var buyGame = new BuyGameModel()
{
Date = DateTime.Now,
FirstName = "Cezary",
Email = "Walenciuk@c.com",
LastName = "Walenciuk",
Game = new GameModel()
{
Id = 1,
Name = "Mortal Kombat"
}
};
controller.BuyGame(buyGame);
}
Pora stworzyć interfejs do GameBuyingRequestProcessor w projekcie GameShop.Core. Nie chcemy w testach jednostkowych operować na prawdziwych klasach i tworzyć silne powiązania między nimi. Przerobiliśmy to w poprzednich wpisach.
Interfejs wygląda tak:
public interface IGameBuyingRequestProcessor
{
GameBuyingResult BuyGame(GameBuyingRequest request);
}
Teraz do HomeController mogę dodać mechanizm wstrzykiwania zależności i otworzyć go do testów jednostkowych.
public class HomeController : Controller
{
private IGameBuyingRequestProcessor _processor;
public HomeController(IGameBuyingRequestProcessor processor)
{
_processor = processor;
}
W teście chcemy stworzyć MOCK tego procesora i dlatego dodajemy paczkę NuGet Moq do projektu GameShop.Web.Test
Tworzy MOCK w następujący sposób:
[Fact]
public void ShouldCallGameBuyingRequestProcessor()
{
var processorMock = new Mock<IGameBuyingRequestProcessor>();
var controller = new HomeController(processorMock.Object);
var buyGame = new BuyGameModel()
{
FirstName = "Cezary",
Email = "Walenciuk@c.com",
LastName = "Walenciuk",
Game = new GameModel()
{
Id = 99,
Name = "Mortal Kombat"
}
};
controller.BuyGame(buyGame);
}
Modyfikujemy metodę BuyGame, aby mogła przyjąć model zakupu gry.
public IActionResult BuyGame(BuyGameModel buyGame)
{
return View();
}
Test muszę znowu zmodyfikować. Pamiętaj, że chcemy także sprawdzić w tym teście czy metoda przetłumaczy obiekt modelu na request. Ostatecznie nasz test wygląda tak:
[Fact]
public void ShouldCallGameBuyingRequestProcessor()
{
//Arange
var processorMock = new Mock<IGameBuyingRequestProcessor>();
var controller = new HomeController(processorMock.Object);
var buyGame = new BuyGameModel()
{
FirstName = "Cezary",
Email = "Walenciuk@c.com",
LastName = "Walenciuk",
Game = new GameModel()
{
Id = 99,
Name = "Mortal Kombat"
}
};
var request = new GameBuyingRequest()
{
FirstName = "Cezary",
LastName = "Walenciuk",
Email = "Walenciuk@c.com",
Date = DateTime.Now,
GameToBuy = new Game() { Id = 99, Name = "Mortal Kombat" }
};
//Act
controller.BuyGame(buyGame);
//Assert
Assert.Equal(buyGame.FirstName, request.FirstName);
Assert.Equal(buyGame.LastName, request.LastName);
Assert.Equal(buyGame.Email, request.Email);
Assert.Equal(buyGame.Game.Id, request.GameToBuy.Id);
Assert.Equal(buyGame.Game.Name, request.GameToBuy.Name);
processorMock.Verify(x => x.BuyGame
(It.IsAny<GameBuyingRequest>()), Times.Once);
}
Zauważ, że porównuje prawie wszystkie pola oprócz pola Date. Pole Date nie występuje w modelu, ale istnieje w request.
Nasz test oczywiście daje wynik na czerwono, gdyż nie mamy logiki obsługujący ten przypadek.
Wychodzimy z fazy czerwonej. Przechodzimy do fazy zielonej, czyli piszemy kod, który sprawi, że ten test wyjdzie na zielono.
Oto nowa metoda BuyGame w kontrolerze HomeController
public IActionResult BuyGame(BuyGameModel buyGame)
{
var request = new GameBuyingRequest()
{
Email = buyGame.Email,
FirstName = buyGame.FirstName,
LastName = buyGame.LastName,
};
request.Date = DateTime.Now;
request.GameToBuy = new Game()
{
Id = buyGame.Game.Id,
Name = buyGame.Game.Name
};
_processor.BuyGame(request);
return View();
}
Teraz nasz test wykona się na zielono. Jesteśmy w fazie refactoring. Jeśli uruchomisz aplikację ASP.NET CORE, to powinieneś zobaczyć następujący błąd.
Musimy w kontenerze wstrzykiwania zależności ASP.NET CORE, powiedzieć co siedzi pod interfejsem IGameBuyingRequestProcessor.
Pamiętaj też, że GameBuyingRequestProcessor jest zależne od innych interfejsów repozytoriów, a one są w projekcie GameShop.DataAccess. Dodajemy więc referencję do nich do projektu GameShop.Web:
Oto deklaracja wstrzyknięcia w ASP.NET CORE w klasie startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddTransient<IGameBuyingRequestProcessor, GameBuyingRequestProcessor>();
services.AddTransient<IGameBoughtOrderRepository, GameBoughtOrderRepository>();
services.AddTransient<IGameRepository, GameRepository>();
Oczywiście to nie koniec kłopotów. Pamiętaj, że nasze repozytoria korzystają z kontekstów Entity Framework. Instalujemy więc paczkę Entity Framework stworzona z myślą o bazie danych w SQL Server.
Pełny kod wstrzykiwania zależności wyglądałby teraz tak:
public void ConfigureServices(IServiceCollection services)
{
services.AddTransient<IGameBuyingRequestProcessor, GameBuyingRequestProcessor>();
services.AddTransient<IGameBoughtOrderRepository, GameBoughtOrderRepository>();
services.AddTransient<IGameRepository, GameRepository>();
services.AddDbContext<GameContext>(options =>
options.UseSqlServer(Configuration.
GetConnectionString("DefaultConnection")));
services.AddDbContext<GameBoughtOrderContext>(options =>
options.UseSqlServer(Configuration.
GetConnectionString("DefaultConnection")));
services.AddControllersWithViews();
}
Do appsettings.json dodaje następującego ConnectionString. Czyli adres do połączenia się z bazą.
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=GameShop;Trusted_Connection=True;MultipleActiveResultSets=true"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}
Oczywiście nie zmienia to faktu, że nie mamy bazy SQL Server, aby ten projekt zadziałał.
W tej serii koncentrujemy się na testach, ale pod koniec wypadałoby mieć kompletną aplikację ASP.NET CORE. :) Nie sądzisz.
Tyle, jeśli chodzi o tę fazę refactoringu, przechodzimy do kolejnego wymogu.
Chcemy zablokować zakup, gdy model jest nieprawidłowy
Dodajemy więc kolejny test.
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
public void ShouldCallGameBuyingRequestProcessorIfModelIsValid
(int expectedNumberOfCalls, bool isModelValid)
{
//Arange
var processorMock = new Mock<IGameBuyingRequestProcessor>();
var controller = new HomeController(processorMock.Object);
if (!isModelValid)
{
controller.ModelState.AddModelError("JustTest", "AnErrorMessage");
}
var buyGame = new BuyGameModel() { Game = new GameModel() };
//Act
controller.BuyGame(buyGame);
//Assert
processorMock.Verify(x => x.BuyGame
(It.IsAny<GameBuyingRequest>()), Times.Exactly(expectedNumberOfCalls));
}
Korzystając z data-driven-test, stworzyłem test, który sprawdzi oba przypadki na raz. Test oczywiście jeden z testów jest na czerwono.
W kontrolerze HomeController wystarczy dodać warunek sprawdzający stan modelu.
public IActionResult BuyGame(BuyGameModel buyGame)
{
var request = new GameBuyingRequest()
{
Email = buyGame.Email,
FirstName = buyGame.FirstName,
LastName = buyGame.LastName,
};
request.Date = DateTime.Now;
request.GameToBuy = new Game()
{
Id = buyGame.Game.Id,
Name = buyGame.Game.Name
};
if (ModelState.IsValid)
{
_processor.BuyGame(request);
}
return View();
}
...i oba testy są już na zielono
W następnym wpisie zrobimy testy do naszych modeli i miejmy nadzieje, że zakończymy ten projekt TDD.