Pisanie testów nie jest najciekawszą częścią tworzenia nowej funkcjonalności, ale jest bardzo ważne. Dobrze napisane testy pomogą Ci upewnić się, że nowo dodana funkcjonalność nie złamie żadnej innej. Jednak podczas testowania DAO i Services możesz zauważyć pewną powtarzalność w swoich testach. Na szczęście możesz stworzyć testy wielokrotnego użytku.

Trochę abstrakcji

Aby stworzyć testy dla podstawowego CRUD DAO i Service musimy stworzyć kilka cech, które nam pomogą.

Pierwszą cechą, która będzie potrzebna jest BaseDAO. Powinna ona wyglądać podobnie jak trait w poniższym snippecie.

trait BaseDAO[T] {
   def count: Future[Long]
   def delete(id: UUID): Future[Option[String]]
   def find(id: UUID): Future[Option[T]]
   def save(data: T): Future[Option[T]]
   def update(data: T): Future[Option[T]]
}

Jak widać rozszerzając tę cechę będziemy musieli zaimplementować podstawowe metody CRUD oraz metodę count. Powinna ona zwracać liczbę elementów przechowywanych w bazie danych.

Kolejnym krokiem jest stworzenie podobnej cechy dla BaseService. Powinna ona wyglądać jak ta przedstawiona poniżej.

trait BaseService[T] {
   def count: Future[Long]
   def delete(id: UUID): Future[Option[String]]
   def retrieve(id: UUID): Future[Option[T]]
   def save(data: T): Future[Option[T]]
   def update(data: T): Future[Option[T]]
}

Jak widać, ta cecha implementuje również podstawowe metody CRUD oraz count.

Ostatnią cechą, którą będziemy musieli stworzyć jest BaseModel który będzie reprezentował podstawowy model danych, który będzie wykorzystywany przez DAO i Services. Powinna ona wyglądać następująco:

trait BaseModel {
   def id: UUID
}

Dzięki temu będziemy mieli dostęp do id modelu w testach.

Ponieważ stworzyliśmy wszystkie podstawowe cechy, musimy je zaimplementować we wszystkich DAO, Usługach i modelach, które używają podstawowego CRUD.

Tworzenie testów DAO wielokrotnego użytku

Aby stworzyć testy wielokrotnego użytku dla DAO, będziemy musieli stworzyć pomocniczą cechę BaseDAOSpec. Ta cecha będzie zawierała wszystkie testy dla podstawowego DAO. Powinna ona wyglądać następująco:

trait BaseDAOSpec[T]{
   this: PlaySpec =>

   def baseDAO(dao: BaseDAO[T], newModel: T, updatedModel: T) = {
       "baseDAO" should {
           "return 0 for empty table" in {
               waitFor(dao.count) mustBe 0
           }

           "save model" in {
               waitFor(dao.save(newModel)) mustBe Some(newModel)
           }
           // ... more tests here
       }
   }
} 

Metoda baseDAO powinna zawierać wszystkie testy dla DAO. Jak widać przyjmuje ona kilka parametrów. Pierwszy z nich to instancja DAO, która będzie testowana, newModel o instancja modelu danych, który zostanie zapisany do bazy danych, a updatedModel to zaktualizowana wersja tego modelu – ze zmienionymi kilkoma polami.

Teraz wykorzystajmy te testy na UserDAO. Implementacja będzie wyglądała tak:

class UserDAOSpec extends PlaySpec with GuiceOneAppPerSuite with play.api.test.Injecting with BaseDAOSpec[User] with WithInMemoryDatabase {
   private implicit val executionContext: ExecutionContext = inject[ExecutionContext]
  
   val userDAO = new UserDAOImpl(databaseConnector)
   val newUser: User = User(UUID.randomUUID(), "Test User", ...)
   val updatedUser: User = newUser.copy(fullname = "Updated User")
  
   "UserDAO" should {
       behave like baseDAO(userDAO, newUser, updatedUser)
   }
}

Jak widać, klasa ta rozszerza kilka cech. Pierwszą z nich jest PlaySpec – styl testowy dla aplikacji Play, kolejną jest GuiceOneAppPerSuite, która zapewni nową instancję Application dla każdego testu. Następnie rozszerza play.api.test.Injecting, który zapewnia sposób na wstrzykiwanie instancji. Na koniec rozszerza nasz BaseDAOSpec[User] o typ User. Ostatnią cechą, której będziemy potrzebować jest WithInMemoryDatabase, która zapewnia bazę danych pamięci dla testów (będziesz musiał zaimplementować ją samodzielnie). Następnie, ponieważ moje DAO używa Future do asynchronicznego dostępu do danych, będziemy musieli wstrzyknąć ExecutionContext. W kolejnych trzech linijkach stworzymy DAO, które będziemy testować – UserDAOImpl oraz dwóch Userów – nowego, który będzie tworzony od zera oraz zaktualizowanego, który będzie kopią poprzedniego, z inną nazwą. Będzie on nam potrzebny do przetestowania metody update w DAO. Kolejne dwie linie uruchamiają wszystkie testy zaimplementowane w BaseDAOSpec.

Tworzenie testów usług wielokrotnego użytku

W podobny sposób można napisać testy wielokrotnego użytku dla usług. Ponieważ usługi w większości zależą od DAO lub innych usług w zakresie dostępu do danych, będziemy musieli stworzyć mocks, dzięki czemu będziemy testować warstwę usług niezależnie.

Cecha BaseServiceSpec będzie wyglądać nieco inaczej niż DAO. Ponieważ jest bardziej prawdopodobne, że twój serwis będzie musiał zrobić więcej rzeczy podczas wykonywania metod CRUD, stworzymy wiele zestawów testów wielokrotnego użytku. Po pierwsze, nasza cecha powinna wyglądać tak:

trait BaseServiceSpec[T] extends WithInMemoryDatabase with MockitoSugar {
this: PlaySpec =>
    def getMockDAO: BaseDAO[T]
    def createServiceWithMockedDAO(mockDAO: BaseDAO[T]): BaseService[T]

    def serviceWithBasicSave(newModel: T) = {...}
    def serviceWithBasicUpdate(updatedModel: T) = {...}
    def serviceWithBasicFind(newModel: T) = {...}
    def serviceWithBasicDelete = {...}

    def baseService(newModel: T, updatedModel: T) = {
        behave like serviceWithBasicCount
        behave like serviceWithBasicSave(newModel)
        behave like serviceWithBasicUpdate(updatedModel)
        behave like serviceWithBasicFind(newModel)
        behave like serviceWithBasicDelete
    }
} 

Pierwsze dwie metody pozostaną abstrakcyjne – zaimplementujemy je później w każdym ServiceSpecu. Kolejne cztery metody powinny być zaimplementowane w tej cesze. Będą one zawierały zestawy testów dla każdej cechy naszego CRUD.

Mokowanie metod DAO

Ponieważ chcemy testować tylko warstwę Service, będziemy musieli wykpić funkcje DAO, dzięki czemu będą one zwracały określoną wartość niezależnie od implementacji DAO. Aby to zobrazować, zaimplementujemy zestaw testowy serviceWithBasicDelete.

Najpierw musimy stworzyć test:

 def serviceWithBasicDelete = {
   "behave like Service with basic delete" should {
       "return None on delete existing model" in {
           val id = UUID.randomUUID()
  
           val mockDAO = getMockDAO
           when(mockDAO.delete(id)).thenReturn(Future.successful(None))
           val service = createServiceWithMockedDAO(mockDAO)
  
           waitFor(service.delete(id)) mustBe None
       }
   }
}

Jak widać, wykorzystaliśmy zarówno metodę getMockDAO, jak i createServiceWithMockedDAO. Za chwilę przejdziemy do ich implementacji. Najpierw wyjaśnijmy jak działa ten kod. Najpierw tworzymy UUID. Następnie wywołujemy metodę, która zwróci nam wyśmiewaną instancję DAO. Następnie wyśmiewamy zwrot metody delete, więc za każdym razem gdy otrzyma ona wcześniej utworzony UUID, zwróci None. Następnie tworzymy instancję usługi, która używa tego wyśmiewanego DAO. Ostatnim krokiem jest wywołanie service.delete(id), które wewnętrznie wywoła delete(id) na wyśmiewanym DAO. Dzięki temu będziemy mogli sprawdzić czy serwis poprawnie przekazuje wynik naszej metody DAO.

Używanie testów usług wielokrotnego użytku

Teraz zaimplementujmy ten BaseServiceSpec i przetestujemy z nim UserService Powinno to wyglądać podobnie do tego:

class UserServiceSpec extends PlaySpec with GuiceOneAppPerSuite with play.api.test.Injecting with BaseServiceSpec[User] {
   private implicit val executionContext: ExecutionContext = inject[ExecutionContext]

   val newUser: User = User(UUID.randomUUID(), None, RoleType.ADMIN, CredentialsProvider.ID, "test@goodsoft.pl", "test1", "Test User")
   val updatedUser: User = newUser.copy(fullname = "Updated User")

   override def getMockDAO: UserDAOImpl = mock[UserDAOImpl]
   override def createServiceWithMockedDAO(mockDAO: BaseDAO[User]): UserServiceImpl = new UserServiceImpl(mockDAO.asInstanceOf[UserDAOImpl])

   "UserService" should {
       behave like baseService(newUser, updatedUser)
   }
}

Analogicznie do DAO rozszerzamy podobne cechy, następnie tworzymy kilka jawnych wartości oraz kilka wartości pomocniczych (newUser i updatedUser). Ponieważ BaseServiceSpec deklaruje getMockDAO oraz createServiceWithMockedDAO musimy je tutaj zaimplementować. Pierwsza metoda zwróci nam podstawowy mock UserDAOImpl. Druga funkcja przyjmuje jako argument wyśmiewane DAO i tworzy instancję UserServiceImpl. Obie te metody wykorzystaliśmy w BaseServiceSpec. Na koniec musimy uruchomić cały zestaw testów CRUD.

Korzystając z tego podejścia do tworzenia testów, możesz łatwo dodać testy, które sprawdzą kilka podstawowych funkcjonalności Twojego kodu. Dzięki temu będziesz mógł skupić się na tworzeniu testów tylko dla bardziej skomplikowanych funkcji.