Wprowadzenie
W poprzednich dwóch wpisach (Postal – wysyłka email w ASP.NET MVC oraz Hangfire – wysyłka email w tle) pokazałem, jak wysyłać wiadomości e-mail w aplikacji ASP.NET MVC. Jeśli nie czytałeś/czytałaś tamtych artykułów, to zachęcam do nadrobienia lektury, szczególnie że w tym wpisie będę bazował właśnie na kodzie z poprzednich wpisów. W dzisiejszym poście chciałbym jeszcze pozostać przy tej tematyce i pokażę Ci, w jaki sposób można automatycznie testować kod odpowiedzialny za wysyłkę wiadomości w ASP.NET MVC.
Wysłanie e-mail możemy przetestować na dwa sposoby:
- Testy jednostkowe – sprawdzamy, czy metoda Send z IEmailService (interface pochodzi z biblioteki Postal) została wywołana z określonymi parametrami
- Testy integracyjne – możemy skorzystać z testowego serwer SMTP, wysłać fizycznie wiadomość i później sprawdzić w serwerze jej właściwości.
W tym wpisze pokażę oba te sposoby. Zachęcamdo pobrania kodu przykładu z githuba – https://github.com/danielplawgo/PostalAndHangfire
Jest to ten sam przykład, w którym pokazywałem wysyłkę wiadomości za pomocą bibliotek Hangfire oraz Postal. Dodałem do niego projekt z testami.
Zmiany w kodzie
Aby móc automatycznie przetestować (w szczególności za pomocą testów jednostkowych) działanie wysyłania wiadomości e-mail, musiałem zmienić nieco kod w klasie BaseMailer:
public class BaseMailer | |
{ | |
private Lazy<IEmailServiceFactory> _emailServiceFactory; | |
protected IEmailServiceFactory EmailServiceFactory | |
{ | |
get { return _emailServiceFactory.Value; } | |
} | |
public BaseMailer(Lazy<IEmailServiceFactory> emailServiceFactory) | |
{ | |
_emailServiceFactory = emailServiceFactory; | |
} | |
protected void Send(Email email) | |
{ | |
var emailService = EmailServiceFactory.Create(GetType()); | |
emailService.Send(email); | |
} | |
} |
W poprzedniej wersji BaseMailer w metodzie Send ręcznie tworzona była instancja klasy EmailService. W tym momencie zdecydowałem się na wprowadzenie nowego elementu, który jest odpowiedzialny za tworzenie EmailService. Dodałem IEmailServiceFactory, aby z jednej strony łatwiej można było testować klasy „*Mailer” za pomocą testów jednostkowych, a z drugiej strony aby logiki tworzenia EmailService nie zamykać w konfiguracji kontenera Autofac.
Sama klasa EmailServiceFactory wygląda tak:
public class EmailServiceFactory : IEmailServiceFactory | |
{ | |
private Lazy<IHostingEnviromentService> _hostingEnviromentService; | |
protected IHostingEnviromentService HostingEnviromentService | |
{ | |
get { return _hostingEnviromentService.Value; } | |
} | |
public EmailServiceFactory(Lazy<IHostingEnviromentService> hostingEnviromentService) | |
{ | |
_hostingEnviromentService = hostingEnviromentService; | |
} | |
public IEmailService Create(Type mailerType) | |
{ | |
var mailerName = mailerType.Name.Replace("Mailer", string.Empty); | |
var viewsPath = Path.GetFullPath(string.Format(HostingEnviromentService.MapPath(@"~/Views/Emails/{0}"), mailerName)); | |
var engines = new ViewEngineCollectionWithoutResolver(); | |
engines.Add(new FileSystemRazorViewEngine(viewsPath)); | |
return new EmailService(engines); | |
} | |
private class ViewEngineCollectionWithoutResolver : ViewEngineCollection | |
{ | |
public ViewEngineCollectionWithoutResolver() | |
{ | |
var resolverField = typeof(ViewEngineCollection).GetField("_dependencyResolver", | |
BindingFlags.NonPublic | BindingFlags.Instance); | |
var resolver = new EmptyResolver(); | |
resolverField.SetValue(this, resolver); | |
} | |
private class EmptyResolver : IDependencyResolver | |
{ | |
public object GetService(Type serviceType) | |
{ | |
return null; | |
} | |
public IEnumerable<object> GetServices(Type serviceType) | |
{ | |
return Enumerable.Empty<object>(); | |
} | |
} | |
} | |
} | |
public interface IEmailServiceFactory | |
{ | |
IEmailService Create(Type mailerType); | |
} |
W EmailServiceFactory znajduje się teraz kod odpowiedzialny na konfigurację silnika Razor, aby możliwa była wysyłka wiadomości e-mail bez kontekstu HTTP. W tym kodzie również nastąpiła drobna zmiana, aby można było go testować.
Wcześniejsza implementacja wykorzystywała klasę HostingEnvironment oraz metodę MapPath z tej klasy do mapowania ścieżki w ramach projektu ASP.NET MVC do fizycznej lokalizacji na dysku. Niestety w przypadku uruchamiania testów (dokładnie testów integracyjnych, o których będzie później) ta metoda zwraca błąd, dlatego trzeba ją opakować nowym interfejsem i w przypadku testów wykonać nieco inną logikę. Poniżej kod samego interfejsu oraz dwóch implementacji – jednej wykorzystywanej w aplikacji, drugiej w testach:
public interface IHostingEnviromentService | |
{ | |
string MapPath(string path); | |
} |
public class HostingEnviromentService : IHostingEnviromentService | |
{ | |
public string MapPath(string path) | |
{ | |
return HostingEnvironment.MapPath(path); | |
} | |
} |
public class TestHostingEnviromentService : IHostingEnviromentService | |
{ | |
public string MapPath(string path) | |
{ | |
var basePath = Directory.GetParent(AppDomain.CurrentDomain.BaseDirectory).Parent.Parent.FullName; | |
return path.Replace("~", Path.Combine(basePath, "PostalAndHangfire")).Replace("/", "\\"); | |
} | |
} |
Logika w TestHostingEnviromentService zmienia katalog, w którym są wyszukiwane widoki. Podczas wykonywania testów widok szukany byłby w katalogu projektu z testami, gdzie go nie ma. Dlatego w tej implementacji IHostingEnviromentService mapuje katalog na folder właściwej aplikacji ASP.NET MVC, dzięki czemu testy integracyjne będą działać.
Testy jednostkowe
Do wykonywania testów jednostkowych oraz integracyjnych wykorzystuję bibliotekę xUnit (https://xunit.net/) oraz Moq (https://github.com/Moq/moq4/) do atrap. Do testowej aplikacji dodałem nowy projekt (PostalAndHangfire.Tests), w którym będą znajdowały się wszystkie testy. W normalnej aplikacji można utworzyć oddzielne projekty dla testów jednostkowych oraz integracyjnych.
Testy jednostkowe będą testowały poprawne wywołanie metody Send z IEmailService. W praktyce testować będziemy logikę zamiany danych przychodzących w obiektach domenowych na klasy e-mail. Nie będziemy testowali samej wysyłki wiadomości oraz poprawności wygenerowania treści e-mail – zrobimy to w testach integracyjnych.
Tworząc testy, bardzo często wykorzystuję klasy bazowe do wrzucania wspólnej logiki dla testów – czy to dla testów grup klas (tak jak w tym przypadku, gdzie klasa bazowa będzie dla wszystkich testów klas „*Mailer”), czy dla testów, w których pojedyncza klasa testów zawiera testy jednej metody. W przykładzie klasa BaseUnitTests zawiera konfigurację atrap, która jest taka sama dla wszystkich klas „*Mailer”:
public class BaseUnitTests | |
{ | |
protected Mock<IEmailServiceFactory> EmailServiceFactory { get; set; } | |
protected Mock<IEmailService> EmailService { get; set; } | |
protected void CreateMocks() | |
{ | |
EmailServiceFactory = new Mock<IEmailServiceFactory>(); | |
EmailService = new Mock<IEmailService>(); | |
EmailServiceFactory.Setup(s => s.Create(It.IsAny<Type>())) | |
.Returns(EmailService.Object); | |
} | |
} |
Jak widać, tworzę dwie atrapy, a następnie konfiguruję atrapę IEmailServiceFactory, aby metoda Create zwracała atrapę IEmailService.
Sam test jednostkowy wygląda tak:
public class UsersMailerUnitTests : BaseUnitTests | |
{ | |
protected User User = new User() | |
{ | |
FirstName = "Daniel", | |
Email = "daniel@plawgo.pl" | |
}; | |
protected UsersMailer Create() | |
{ | |
CreateMocks(); | |
return new UsersMailer(new Lazy<IEmailServiceFactory>(() => EmailServiceFactory.Object)); | |
} | |
[Fact] | |
public void Invoke_EmailService() | |
{ | |
var usersMailer = Create(); | |
usersMailer.SendRegisterEmail(User); | |
EmailService.Verify(s => s.Send(It.Is<RegisterEmail>(e => e.Email == "daniel@plawgo.pl" || e.FirstName == "Daniel"))); | |
} | |
} |
Tak jak wspomniałem, w teście jednostkowym sprawdzamy, czy metoda Send z IEmailService została wywołana z odpowiednimi parametrami. W testach wykorzystuję metody Create, które tworzą testowany obiekt z atrapami zależności, aby nie powielać tego kodu w każdym teście.
Testy integracyjne
Testy jednostkowe były dość proste i testowały tylko fragment kodu wysyłki wiadomości e-mail. Testy integracyjne są bardziej rozbudowane i umożliwią przetestowanie całej logiki, w tym i generowanie treści wiadomości.
Do implementacji testów potrzebny będzie nam serwer SMTP, abyśmy mogli sprawdzić, czy wiadomość została wysłana poprawnie. Do tego celu wykorzystuję bibliotekę netDumbster (https://github.com/cmendible/netDumbster), która właśnie tworzy taki serwer w locie i umożliwia łatwe wyciągnięcie informacji o wysłanych wiadomościach.
Po zainstalowaniu biblioteki z nugeta musimy jeszcze w pliku app.config z projektu z testami dodać informacje o serwerze SMTP, który będzie wykorzystywany podczas testów. Robi się to bardzo podobnie, jak w przypadku prawdziwego serwera:
<system.net> | |
<mailSettings> | |
<smtp from="test@email.com"> | |
<network host="localhost" port="25" userName="" password="" /> | |
</smtp> | |
</mailSettings> |
W przypadku netDumbster w app.config podajemy dwie rzeczy: host (localhost) oraz port (np. 25).
W testach jednostkowych również wykorzystuję klasę bazową (BaseIntegrationTests), w której teraz znajdują się pomocnicze metody tworzące tym razem realne zależności dla testowanych klas „*Mailer”:
public class BaseIntegrationTests | |
{ | |
protected Lazy<IEmailServiceFactory> CreateEmailServiceFactory() | |
{ | |
return new Lazy<IEmailServiceFactory>(() => | |
new EmailServiceFactory(new Lazy<IHostingEnviromentService>(() => new TestHostingEnviromentService()))); | |
} | |
} |
Sam test integracyjny nie jest dużo bardziej rozbudowany od testu jednostkowego. Dochodzi przede wszystkim uruchomienie testowego serwera SMTP (podajemy w parametrze port z app.config) z biblioteki netDumbster oraz późniejsza seria asertów, które sprawdzają poprawność wysłanej wiadomości e-mail wyciągniętej z testowego serwera SMTP:
public class UsersMailerIntegrationTests : BaseIntegrationTests | |
{ | |
protected User User = new User() | |
{ | |
FirstName = "Daniel", | |
Email = "daniel@plawgo.pl" | |
}; | |
protected UsersMailer Create() | |
{ | |
return new UsersMailer(CreateEmailServiceFactory()); | |
} | |
[Fact] | |
public void SendRegisterEmail() | |
{ | |
using (var server = SimpleSmtpServer.Start(25)) | |
{ | |
var usersMailer = Create(); | |
usersMailer.SendRegisterEmail(User); | |
Assert.Equal(1, server.ReceivedEmailCount); | |
var email = server.ReceivedEmail.FirstOrDefault(); | |
Assert.NotNull(email); | |
Assert.Equal("daniel@plawgo.pl", email.ToAddresses[0].Address); | |
Assert.Equal("test@email.com", email.FromAddress.Address); | |
Assert.Equal("Nowe konto", email.Headers["Subject"]); | |
var body = email.MessageParts[0].BodyData.FromBase64(); | |
Assert.Contains("Witaj Daniel!", body); | |
Assert.Contains("Dziękujemy za założenie konta.", body); | |
} | |
} | |
} |
W przypadku testowania treści wiadomości osobiście skupiam się na występowaniu poszczególnych fraz (dlatego w przykładzie są dwa aserty). Wiadomości e-mail bardzo często są zbudowane z HTML-a, który z czasem może się zmieniać w zależności od tego, jak chcemy, aby wiadomość wyglądała, sam tekst rzadziej się zmienia. Zauważyłem, że utrzymanie później takiego testu jest bardziej czasochłonne niż gdybyśmy robili asserta dla całej zawartości wiadomości (wraz z kodem HTML). Ale decyzja, który sposób chcesz wykorzystać, należy do Ciebie.
Podsumowanie
Testy automatyczne w naszych aplikacjach są bardzo ważnym elementem utrzymania wysokiej jakości oprogramowania. Dlatego warto testować wszystko w sposób automatyczny. Mam nadzieję, że już wiesz, w jaki sposób przetestować wysyłkę wiadomości w aplikacji ASP.NET MVC z wykorzystaniem testów jednostkowych oraz integracyjnych (dzięki bibliotece netDumbster).
Zaktualizowany kod przykładu znajdziesz na githubie (https://github.com/danielplawgo/PostalAndHangfire) – zachęcam do pobrania i sprawdzenia w praktyce, jak to wszystko działa.
A Ty testujesz automatycznie wysłanie wiadomości e-mail? Czego używasz?
1 thought on “Testowanie wysyłki e-mail w ASP.NET MVC”