Test doubles: una panoramica

Parlando di test automatici, sarà inevitabile incappare in termini come mock, stub o fake. Questi oggetti vengono definiti “test doubles”, e ci aiutano a riprodurre comportamenti prevedibili all’interno dei nostri unit test o test d’integrazione.

Non sempre però è facile ricordare le differenze tra le varie tipologie, e così si finisce per usare il termine “mockare” in maniera indiscriminata.

In questo post ho quindi provato a raccogliere un po’ di definizioni ed esempi che chiariscano le caratteristiche di ciascun tipo di test double ed i suoi casi d’uso.

Premessa

Un test double può essere visto come la “controfigura” di un oggetto reale presente nella nostra applicazione, avente però una logica semplificata e controllabile.

In uno scenario object oriented, questo “doppione” dovrà implementare la stessa interfaccia dell’oggetto originale, così che, a tutti gli effetti, potremo sostituirlo in tutti i contesti in cui l’originale viene utilizzato.

Prima di esaminare i vari tipi di test double, cominciamo col definire un ipotetico sistema sotto test — nella fattispecie, la classe SignUpService — e alcune sue dipendenze che andremo ad utilizzare negli esempi successivi:

class SignUpService(private val userRepository: UserRepository) {
  fun signUp(signUpRequest: SignUpRequest): UserDTO {
    if (signUpRequest.isValid) {
      if (userRepository.existsUserWithEmail(signUpRequest.email))
        throw IllegalArgumentException("Email ${signUpRequest.email} already used")

      val user = userRepository.saveUser(signUpRequest.mapToUser())
      return user.mapToUserDTO()
    } else
      throw IllegalArgumentException("Invalid sign up request")
  }
}

open class SignUpRequest(val userName: String, val email: String, val password: String) {
  open val isValid: Boolean
      get() = userName.isNotBlank()
          && email.isNotBlank()
          && password.isNotBlank()
}

interface UserRepository {
  fun saveUser(user: User): User
  fun existsUserWithEmail(email: String): Boolean
}

A questo link trovate il codice completo del System Under Test (SUT).

Dummy

Un Dummy Object — o semplicemente Dummy — è la forma più semplice di test double. Si tratta di una replica che, a tutti gli effetti, non implementa un comportamento.

Possiamo usare un dummy per sostituire l’oggetto originale in test in cui il comportamento di quest’ultimo è ininfluente.

Un dummy, nella sua forma più semplice, può essere costituito da una null reference.

Vediamo un esempio:

class DummyUserRepository : UserRepository {
  override fun saveUser(user: User): User =
      throw UnsupportedOperationException()

  override fun existsUserWithEmail(email: String): Boolean =
      throw UnsupportedOperationException()
}

L’eccezione UnsupportedOperationException, sollevata da entrambi i metodi di DummyUserRepository, fa sì che il nostro dummy non venga inavvertitamente utilizzato in contesti di produzione. Allo stesso tempo, se in fase di test dovessimo incappare in un eccezione di questo tipo avremmo evidenza del fatto che i metodi vengono invocati, quando invece non dovrebbero.

@Test
fun `should throw an exception when sign up request is not valid`() {
  val signUpService = SignUpService(DummyUserRepository())

  assertThrows<IllegalArgumentException> {
    signUpService.signUp(InvalidSignUpRequestStub())
  }
}

Il test verifica che, per un input non valido, il metodo signUp scateni un eccezione di tipo IllegalArgumentException.

Chiaramente, in questa situazione l’oggetto DummyUserRepository può essere letteralmente ignorato, visto che non è coinvolto in alcun modo nel test. Il costruttore di SignUpService dichiara però un parametro di tipo UserRepository, per cui sarà necessario fornire un’istanza corrispondente.

NOTA In linguaggi come Java, che non implementano meccanismi di null-safety, avremmo potuto passare al costruttore un semplice null come argomento.

Stub

Un Test Stub — o semplicemente Stub — è un oggetto programmato per restituire risposte predefinite.

Anch’esso, come un dummy, non possiede dei veri e propri comportamenti, ma (generalmente) implementa dei valori di ritorno hardcoded, specifici per il test che andremo ad eseguire.

A differenza del dummy, un oggetto stub gioca un ruolo attivo all’interno del test. Uno stub, infatti, viene normalmente utilizzato per guidare il flusso di esecuzione delle logiche applicative che vogliamo testare.

I più attenti avranno notato che già nel test precedente — insieme all’oggetto dummy — abbiamo usato uno stub:

class InvalidSignUpRequestStub : SignUpRequest("", "", "") {
  override val isValid: Boolean
      get() = false
}

La classe InvalidSignUpRequestStub definisce una SignUpRequest ostinatamente non valida, ottenendo quindi un comportamento prevedibile, indipendentemente dalla logica dell’oggetto reale. In questo modo possiamo focalizzarci sull’effettiva parte del sistema che vogliamo testare.

@Test
fun `should throw an exception when sign up request is not valid`() {
  val signUpService = SignUpService(DummyUserRepository())

  assertThrows<IllegalArgumentException> {
    signUpService.signUp(InvalidSignUpRequestStub())
  }
}

Ho riportato per chiarezza il test già visto nel paragrafo precedente.

Utilizzando lo stub InvalidSignUpRequestStub come argomento di signUp, possiamo essere certi che la validazione dell’input fallisca in ogni caso; in questo modo possiamo concentrare l’attenzione sul fatto che l’operazione di registrazione abbia come esito un’eccezione.

Spy

Un Test Spy — o semplicemente Spy — è un oggetto simile ad uno stub, che però implementa una caratteristica molto interessante: permette di “registrare” ed ispezionare le interazioni avvenute con l’oggetto sotto test.

class SuccessfullySavingUserRepositorySpy : UserRepository {
  var numberOfCalls: Int = 0
    private set

  override fun saveUser(user: User): User {
    numberOfCalls++
    return user
  }

  override fun existsUserWithEmail(email: String): Boolean = false
}

La classe SuccessfullySavingUserRepositorySpy, in particolare, implementa un contatore delle invocazioni per il metodo saveUser. In questo modo, durante il test, possiamo sapere se il metodo signUp provvede correttamente al salvataggio tramite lo UserRepository.

@Test
fun `should create a new user when sign up request is valid`() {
  val repositorySpy = SuccessfullySavingUserRepositorySpy()
  val signUpService = SignUpService(repositorySpy)

  val newUser = signUpService.signUp(
    SignUpRequest("user", "[email protected]", "pwd")
  )

  assertEquals(UserDTO("user", "[email protected]"), newUser)
  assertEquals(1, repositorySpy.numberOfCalls)
}

Proprio in scenari come quello dell’esempio, in cui una classe debba interagire con sistemi esterni — come web service o database — l’utilizzo di uno spy risulta particolarmente utile. In questo modo, infatti, si riescono ad implementare test d’unità il cui esito non dipende dalla risposta di sistemi su cui non abbiamo controllo.

Però… c’è un però…

Sebbene questa tipologia di oggetti appaia molto potente e utile, è importante ricordare che l’utilizzo di spie nei nostri test introduce un certo livello di accoppiamento tra il sistema sotto test e il test implementato.

Asserzioni basate sull’ispezione di uno spy, infatti, prevedono la conoscenza di dettagli implementativi del sistema che stiamo testando. Questo rende più fragili i nostri test, che dovrebbero quanto più possibile seguire un approccio black box, basato cioè sul solo comportamento atteso da parte del sistema.

Mock

Un Mock Object — o semplicemente Mock —, come un oggetto spy, viene tipicamente utilizzato come punto di osservazione del sistema sotto test, avendo la capacità di memorizzare le interazioni con quest’ultimo.

Differentemente da quanto avviene con un spy però, in un mock la verifica delle interazioni avviene internamente all’oggetto. In altre parole, un mock può essere visto come uno stub contente delle asserzioni.

class EmailAlreadyUsedUserRepositoryMock(
      private val expectedNumberOfCalls: Int? = null,
      private val expectedEmail: String? = null) : UserRepository {

  private var numberOfCalls: Int = 0

  override fun saveUser(user: User): User =
      throw UnsupportedOperationException()

  override fun existsUserWithEmail(email: String): Boolean {
    numberOfCalls++
    if (expectedEmail != null) assertEquals(expectedEmail, email)
    return true
  }

  fun verify() {
    if (expectedNumberOfCalls != null) 
    assertEquals(expectedNumberOfCalls, numberOfCalls)
  }
}

L’utilizzo di mock cambia l’usuale struttura di un test automatico che, tipicamente, prevede una fase di setup, una di esercizio ed una di verifica, quest’ultima consistente in una serie di asserzioni sull’output generato dal sistema testato.

Utilizzando un mock, le asserzioni si trasformano in precondizioni impostate durante il setup dell’oggetto. Ne consegue che dal test è assente una vera e propria fase di verifica.

@Test
fun `should throw an exception when email is already used`() {
  val repositoryMock = EmailAlreadyUsedUserRepositoryMock(
      expectedNumberOfCalls = 1,
      expectedEmail = "[email protected]"
  )
  val signUpService = SignUpService(repositoryMock)

  assertThrows<IllegalArgumentException> {
    signUpService.signUp(SignUpRequest("user", "[email protected]", "pwd"))
  }

  repositoryMock.verify()
}

A differenza delle altre tipologie di oggetti-replica, i mock vengono difficilmente implementati “a mano”. Tipicamente sono infatti generati “al volo” tramite appositi framework — es. Mockito, JMock, MockK, ecc. — e l’invocazione finale del verify (o metodo analogo) avviene spesso in maniera implicita.

Fake

Un Fake Object — o semplicemente Fake — è il test double più simile all’oggetto replicato, in termini di comportamento.

Questa tipologia di oggetto non prevede logiche di ispezione o risposte hardcoded per i suoi metodi: implementa invece una versione semplificata dell’oggetto utilizzato in produzione.

La differenza fondamentale di un fake rispetto agli altri test double sta dunque nel fatto che esso possiede un vero e proprio comportamento.

Un fake viene utilizzato generalmente in contesti un cui la creazione dell’oggetto risulta difficile, o potrebbe rallentare l’esecuzione del test. Oppure in casi in cui l’utilizzo dell’oggetto originale potrebbe generare side effect indesiderati, in fase di test (per esempio l’interazione con filesystem o sistemi esterni).

class FakeUserRepository(vararg initUsers: User) : UserRepository {
  private val db = mutableListOf(*initUsers)

  override fun saveUser(user: User): User {
    if (db.find { it.userName == user.userName } != null)
      throw IllegalArgumentException("User '${user.userName}' already exists")

    db.add(user)
    return user
  }

  override fun existsUserWithEmail(email: String): Boolean =
      db.any { it.email == email }
}

In questo esempio ho semplicemente implementato uno UserRepository che utilizza una lista come database per gli utenti. In questo modo il comportamento originale del sistema è mantenuto, ma non occorre scomodare DBMS esterni — che tra l’altro introdurrebbero ulteriori variabili da tenere in considerazione in fase di test.

@Test
fun `should throw an exception when username is already used`() {
  val signUpService = SignUpService(FakeUserRepository(User("user", "", "")))

  assertThrows<IllegalArgumentException> {
    signUpService.signUp(SignUpRequest("user", "[email protected]", "pwd"))
  }
}

NOTA Per l’implementazione di unit test — come quelli riportati in questo post —, è importante mantenere l’isolamento dell’unità sotto test rispetto ai sistemi esterni. Ovviamente se stessimo effettuando test d’integrazione, l’interazione con un database esterno non costituirebbe un problema, sarebbe invece auspicabile.

In conclusione

Come avrete notato, benché le differenze tra i vari test double sia a volte sottile, ciascuna tipologia di oggetto è pensata per risolvere problemi specifici e facilitare l’implementazione di test in scenari differenti.

Molto spesso ci ritroveremo ad implementare le nostre controfigure sfruttando mocking framework, senza renderci esattamente conto di quale tipologia di oggetto stiamo utilizzando.

Ad ogni modo, vale sempre la pena considerare un approccio hand-made, che in molte situazioni rende più comprensibile il codice dei nostri test e ci evita l’utilizzo di un’ulteriore dipendenza.

Se vi va, fatemi sapere cosa ne pensate.

Alla prossima,

David

(Photo by Iker Urteaga on Unsplash)