Pisząc testy jednostkowe w aplikacjach, które przechowują dane w bazie danych prędzej, czy później będziemy zmuszeni do odizolowania warstwy dostępu do bazy danych. W opisywanym przypadku jako ORM wykorzystywany jest Entity Framework.

Kod definiujący podstawowe elementy wygląda w następujący sposób:

public class User
{
  public int Id { get; set; }
  public string Login { get; set; }
  public string Name { get; set; }
  public string Surname { get; set; }
  public bool AccountLocked { get; set; }
  public virtual List<Role> Roles { get; set; }
}

public class UsersContext : DbContext
{
  public virtual DbSet<User> Users { get; set; }
  public virtual DbSet<Role> Roles { get; set; }
}

public class UsersService
{
  private readonly UsersContext usersContext;
  
  public UsersService(UsersContext usersContext)
  {
    this.usersContext = usersContext;
  }
 
  public User AddUser(string login, string name, string surname)
  {
    var newUser = this.usersContext.Users.Add(
      new User
      {
        Login = login,
        Name = name,
        Surname = surname,
        AccountLocked = false
      });
  
    this.usersContext.SaveChanges();
    return newUser;
  }
 
  public IList<User> GetLockedUsers ()
  {
    return this.usersContext.Users.Where(x => x.AccountLocked).ToList();
  }
}

Chcąc odizolować warstwę danych od aplikacji można wykorzystać jedną z dwóch opcji:

  • napisać dodatkową implementację klasy UsersContext, która na potrzeby testów jednostkowych będzie symulowała działanie bazy danych. Obiekt nowej klasy zostanie wstrzyknięty do obiektów, które do działania potrzebują klasę UsersContext.
  • wykorzystać jeden z dostępnych frameworków mockujących – w tym przypadku Moq.

Oba rozwiązania mają pewne wady i zalety – pierwsze z nich pozwala na stworzenie środowiska bardzo dopasowanego do naszych potrzeb. Z drugiej strony będziemy musieli napisać dodatkowy kod, który w przyszłości będziemy musieli utrzymywać. Wykorzystując framework mockujący otrzymamy rozwiązanie, które jest mniej dostosowane do naszych wymagań, ale z drugiej strony nie będziemy ponosili kosztów stworzenia dodatkowej implementacji klasy UsersContext oraz późniejszego jej utrzymania. Co więcej, wykorzystanie mocków pozwala na łatwą zmianę zachowania obiektów, które mockujemy.

W opisywanym przykładzie zostaną wykorzystane następujące technologie:

Oczywiście testować będziemy metody z klasy UsersService.

W przypadku tego typów testów należy rozważyć dwa przypadki, zależne od tego czy metoda, którą testujemy wykorzystuje LINQ, w celu pobrania danych – metoda GetLockedUsers, czy też nie – AddUser.

Brak wykorzystania LINQ w metodzie testowanej

Przypadek drugi – bez wykorzystania LINQ – jest prostszy, choć wywołuje u mnie więcej wątpliwości odnośnie sposobu pisania testów jednostkowych. Do testowania metody AddUser można zastosować dwa podejścia:

  • Zaizolować wywołanie this.usersContext.Users.Add, w taki sposób, aby stworzyło poprawny obiekt klasy User, a następne sprawdzić czy wynikiem metody AddUser będzie użytkownik, którego pola mają odpowiednie wartości:
    [Theory, AutoData]
    public void AddUser_Invoke_UserCreated_v1(string expectedLogin, string expectedName, string expectedSurname)
    {
      // Arrange
      var userContextMock = new Mock<UsersContext>();
      userContextMock.Setup(x => x.Users.Add(It.IsAny<User>())).Returns((User u) => u);
     
      var usersService = new UsersService(userContextMock.Object);
     
      // Act
      var user = usersService.AddUser(expectedLogin, expectedName, expectedSurname);
     
      // Assert
      Assert.Equal(expectedLogin, user.Login);
      Assert.Equal(expectedName, user.Name);
      Assert.Equal(expectedSurname, user.Surname);
      Assert.False(user.AccountLocked);
     
      userContextMock.Verify(x => x.SaveChanges(), Times.Once);
    }
    
  • Częściowo zignorować wynik metody AddUser (sprawdzić tylko czy otrzymaliśmy jakiś obiekt) i sprawdzić czy poprawnie wywołaliśmy metodę dodającą obiekt do kontekstu:
    [Theory, AutoData]
    public void AddUser_Invoke_UserCreated_v2(string expectedLogin, string expectedName, string expectedSurname)
    {
      // Arrange
      var userContextMock = new Mock<UsersContext>();
      var usersMock = new Mock<DbSet<User>>();
      usersMock.Setup(x => x.Add(It.IsAny<User>())).Returns((User u) => u);
      userContextMock.Setup(x => x.Users).Returns(usersMock.Object);
     
      var usersService = new UsersService(userContextMock.Object);
     
      // Act
      var user = usersService.AddUser(expectedLogin, expectedName, expectedSurname);
     
      // Assert
      Assert.NotNull(user);
      usersMock.Verify(
        x =>
          x.Add(
            It.Is<User>(
              u =>
                u.Login == expectedLogin && u.Name == expectedName && u.Surname == expectedSurname &&
                !u.AccountLocked)), Times.Once);
      userContextMock.Verify(x => x.SaveChanges(), Times.Once);
    }
    

W obu przypadkach weryfikujemy, czy nastąpiło wywołanie metody SaveChanges.

Wracając do moich wątpliwości – pojawią się one w każdym przypadku, gdy w testach jednostkowych następuje sprawdzenie czy została wykonana jakaś metoda (dodatkowo można również sprawdzić, czy odpowiednie parametry zostały przekazane do tej metody). Wątpliwości dotyczą tego, czy w takich przypadkach sprawdzamy, poprawność działania metody, czy też jej implementację.

Czasami w testach sprawdzamy tylko czy metoda, którą testujemy wywołała inne metody. Przy tego typu testach należy pamiętać, że nie wykryją one błędu wynikającego z faktu zmienienia kolejności wywołania sprawdzanych metod. Z tego powodu bliżej mi do pierwszego zaprezentowanego rozwiązania, chociaż także w nim sprawdzamy, czy metoda SaveChanges została wykonana dokładnie raz.

LINQ w metodzie testowanej

Takie testy to ja lubię – czyste testy jednostkowe. Aby można było wykonać zapytanie LINQ na obiekcie klasy DbSet należy stworzyć listę obiektów (w tym przypadku użytkowników), a następnie powiązać ją z implementacją interfejsu IQueryable<out T> w klasie DbSet. Kod za to odpowiedzialny wygląda w następujący sposób:

var users = new List<User>
  {
    lockedUser,
    fixture.Build<User>().With(u => u.AccountLocked, false).Create(),
    fixture.Build<User>().With(u => u.AccountLocked, false).Create()
  }.AsQueryable();
 
  var usersMock = new Mock<DbSet<User>>();
  usersMock.As<IQueryable<User>>().Setup(m => m.Provider).Returns(users.Provider);
  usersMock.As<IQueryable<User>>().Setup(m => m.Expression).Returns(users.Expression);
  usersMock.As<IQueryable<User>>().Setup(m => m.ElementType).Returns(users.ElementType);
  usersMock.As<IQueryable<User>>().Setup(m => m.GetEnumerator()).Returns(users.GetEnumerator());

W przykładzie można zauważyć w jaki sposób można wykorzystać AutoFixture do tworzenia obiektów.

Cały test wygląda następująco:

[Fact]
public void GetLockedUsers_Invoke_LockedUsers_v1()
{
  // Arrange
  var fixture = new Fixture();
  var lockedUser = fixture.Build<User>().With(u => u.AccountLocked, true).Create();
  var users = new List<User>
  {
    lockedUser,
    fixture.Build<User>().With(u => u.AccountLocked, false).Create(),
    fixture.Build<User>().With(u => u.AccountLocked, false).Create()
  }.AsQueryable();
 
  var usersMock = new Mock<DbSet<User>>();
  usersMock.As<IQueryable<User>>().Setup(m => m.Provider).Returns(users.Provider);
  usersMock.As<IQueryable<User>>().Setup(m => m.Expression).Returns(users.Expression);
  usersMock.As<IQueryable<User>>().Setup(m => m.ElementType).Returns(users.ElementType);
  usersMock.As<IQueryable<User>>().Setup(m => m.GetEnumerator()).Returns(users.GetEnumerator());
 
  var userContextMock = new Mock<UsersContext>();
  userContextMock.Setup(x => x.Users).Returns(usersMock.Object);
 
  var usersService = new UsersService(userContextMock.Object);
 
  // Act
  var lockedUsers = usersService.GetLockedUsers ();
 
  // Assert
  Assert.Equal(new List<User> {lockedUser}, lockedUsers);
}

Ponieważ mockowanie obiektów DbSet w każdym przypadku będzie wyglądało analogicznie proponuję wydzielić kod za to odpowiedzialny do osobnej metody / klasy:

private static Mock<DbSet<T>> CreateDbSetMock<T>(IEnumerable<T> elements) where T : class
{
  var elementsAsQueryable = elements.AsQueryable();
  var dbSetMock = new Mock<DbSet<T>>();
 
  dbSetMock.As<IQueryable<T>>().Setup(m => m.Provider).Returns(elementsAsQueryable.Provider);
  dbSetMock.As<IQueryable<T>>().Setup(m => m.Expression).Returns(elementsAsQueryable.Expression);
  dbSetMock.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(elementsAsQueryable.ElementType);
  dbSetMock.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(elementsAsQueryable.GetEnumerator());
 
  return dbSetMock;
}

Po takim zabiegu nasz test jednostkowy trochę się uprości oraz zwiększy się jego czytelność:

[Fact]
public void GetLockedUsers_Invoke_LockedUsers_v2()
{
  // Arrange
  var fixture = new Fixture();
  var lockedUser = fixture.Build<User>().With(u => u.AccountLocked, true).Create();
  IList<User> users = new List<User>
  {
    lockedUser,
    fixture.Build<User>().With(u => u.AccountLocked, false).Create(),
    fixture.Build<User>().With(u => u.AccountLocked, false).Create()
  };
 
  var usersMock = CreateDbSetMock(users);
 
  var userContextMock = new Mock<UsersContext>();
  userContextMock.Setup(x => x.Users).Returns(usersMock.Object);
 
  var usersService = new UsersService(userContextMock.Object);
 
  // Act
  var lockedUsers = usersService.GetLockedUsers ();
 
  // Assert
  Assert.Equal(new List<User> {lockedUser}, lockedUsers);
}