Testowanie wysyłki e-mail w ASP.NET MVC

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);
}
}
view raw BaseMailer.cs hosted with ❤ by GitHub

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ć.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

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>
view raw app.config hosted with ❤ by GitHub

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

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.