이번 글에서는 scala 를 사용하면서 테스트 코드를 작성하기 위한 환경을 구성해보고, mocking을 이용한 단위 테스트를 작성해보자.
scalatest 의존성을 추가하고 기본적인 단위 테스트를 진행해보자.
testImplementation group: 'org.scalatest', name: 'scalatest_2.12', version: '3.2.17'
아래와 같이 NameService 클래스를 생성 후 테스트를 위한 NameServiceTest 클래스를 생성한다.
class NameService(name: String) {
def printName(): String = {
name
}
}
scalatest에는 여러가지 스타일로 테스트를 작성할 수 있도록 제공하며, 아래는 FunSpec 스타일로 작성했다.
공식문서에서 더 많은 테스트 style 을 살펴보자.
import org.scalatest.funspec.AnyFunSpec
class NameServiceTest extends AnyFunSpec with Matchers {
describe("NameServiceTest") {
it("The name should be kaven") {
val service = new NameService("kaven")
service.printName() shouldBe "kaven"
}
}
}
위에서 describe 키워드는 하나의 테스트 집합이며, it 메소드는 하나의 테스트이고 여러
테스트로 정의 가능하다.
scalatet 테스트 하기 위해 제공하는 여러 기능들에 대해 살펴보자.
각 테스트 시작 전, 후에 실행될 메서드를 before, after 키워드로 정의할 수 있으며,
BeforeAndAfter trait를 상속받으면 된다.
BeforeAndAfterAll trait도 제공하므로 참고하자.
class NameServiceTest extends FunSpec with BeforeAndAfter {
before {
println("before")
}
after {
println("after")
}
}
각 테스트를 pending 상태로 표기
해 둘 수 있다.
it("The name should be kaven") (pending)
또는 아래와 같이 해당 테스트를 disable
시켜 둘 수 있다.
ignore("The name should be kaven") {
// ...
}
exception을 테스트 하기 위해서 intercept 메서드를 이용하여 검증할 수 있다.
val service = new NameService()
val thrown = intercept[Exception] {
service.printName()
}
assert("error" === thrown.getMessage)
scala에서 mock 테스트를 하기 위한 방법은 ScalaMock, EasyMock, JMock, Mockito 등을 이용 할 수 있으며, 자세한 내용은 공식문서를 참고하자.
여기서는 mockito를 이용한 단위테스트를 살펴 볼 것이며, 의존성을 추가해주자.
testImplementation group: 'org.mockito', name: 'mockito-scala_2.12', version: '1.16.23'
아래 예시는 NameService에서 ConfigService를 파라미터로 받아서 이름을 출력하는 예이다.
여기서 ConfigFactory는 외부에 있는 property를 읽어 올 수 있는 클래스이며 아래 의존성을 추가 후 load 하여 사용할 수 있다.
implementation group: 'com.typesafe', name: 'config', version: '1.0.2'
NameService를 테스트 할 때, ConfigService 외부 의존성이 있음을 확인 할 수 있다.
class ConfigService {
def getConfig(): String = {
val config: Config = ConfigFactory.load()
config.getString("domain")
}
}
class NameService(config: ConfigService) {
def printName(): String = {
config.getConfig()
}
}
따라서 단위 테스트 진행을 할 경우 외부 의존성을 mocking 해야 하며,
아래와 같이 가능하다.
MockitoSugar 를 상속받아서 mock 키워드를 사용한다.
class NameServiceTest extends AnyFunSpec with MockitoSugar with GivenWhenThen {
describe("NameServiceTest") {
it("The name should be kaven") {
Given("Given in FunSpec BDD Test")
When("When in FunSpec BDD Test")
val configService = mock[ConfigService]
when(configService.getConfig()).thenReturn("kaven")
val service = new NameService(configService)
Then("Then in FunSpec BDD Test")
assert("kaven" === service.printName())
}
}
}
이때 GivenWhenThen trait를 상속받아 BDD 스타일로 테스트를 작성할 수 있다.
Given, When, Then, And 키워드를 사용할 수 있다.
아래와 같이 singleton object를 사용한 경우 테스트 코드를 작성해보자.
object ConfigSupport {
val config: Config = ConfigFactory.load()
}
object NameService {
def printName(): String = {
ConfigSupport.config.getString("domain")
}
}
object를 mocking하기 위해 mockito-scala에서 제공하는 withOjbectMocked 를
사용하면 된다.
단, withObjectMocked 를 활성화하기 위해 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker 파일을
생성 후 아래 내용을 추가해 줘야 한다.
mock-maker-inline
그 후 아래와 같이 테스트 코드를 작성할 수 있다.
class NameServiceTest extends AnyFunSpec with MockitoSugar with Matchers {
describe("NameServiceTest") {
it("The name should be kaven") {
withObjectMocked[ConfigSupport.type] {
val config = mock[Config]
when(ConfigSupport.config).thenReturn(config)
when(config.getString(anyString())).thenReturn("kaven")
NameService.printName() shouldBe "kaven"
}
}
}
}
아래 예제의 경우 단위 테스트가 가능할까?
trait ConfigSupport {
val config: Config = ConfigFactory.load()
}
object NameService extends ConfigSupport {
def printName(): String = {
config.getString("domain")
}
}
이러한 경우 단위테스트가 불가능하진 않지만, 아래와 같이 리플렉션을 직접 이용하여 단위 테스트 하는 등의 방식으로 진행해야 한다.
class NameServiceTest extends AnyFunSpec with MockitoSugar with Matchers {
describe("NameServiceTest") {
it("The name should be kaven") {
val mockConfig = mock[Config]
when(mockConfig.getString("domain")).thenReturn("kaven")
val nameService = NameService
// Use reflection to override the config field with the mock
val configField = nameService.getClass.getDeclaredField("config")
configField.setAccessible(true)
configField.set(nameService, mockConfig)
val result = nameService.printName()
result shouldBe "kaven"
}
}
}
따라서 코드를 작성할 때 Dependency Injection 로 디자인 하게 되면 서비스 간에 loose coupling 을 향상시킬 수 있고, 테스트하기 쉬운 코드로 관리할 수 있게 된다.
아래와 같이 class 를 통해 로직들을 분리 함으로써 mocking이 가능해진다.
trait ConfigSupport {
val config: Config = ConfigFactory.load()
}
// Config 를 생성사 또는 메서드 파라미터로 전달함으로써 mocking 이 가능해진다.
class NameServiceLogic(config: Config) {
def printName(): String = {
config.getString("domain")
}
}
object NameService extends ConfigSupport {
val logic = new NameServiceLogic(config)
def printName(): String = logic.printName()
}
class로 로직을 분리했기 때문에 외부 의존성인 Config 클래스를 mocking 및 stubbing이
가능해진 것을 확인 할 수 있다.
import com.typesafe.config.{Config, ConfigFactory}
import org.mockito.Mockito.when
import org.scalatest.FunSpec
import org.scalatestplus.mockito.MockitoSugar.mock
class NameServiceTest extends FunSpec {
describe("NameServiceTest") {
it("The name should be kaven") {
val config = mock[Config]
when(config.getString("domain")).thenReturn("kaven")
val service = new NameServiceLogic(config)
assert("kaven" === service.printName())
}
}
}
또한, singleton object를 사용할 때 trait 또는 class를 이용하여 companion object를
만들어 파라미터로 사용하게 되면 mocking 하여
테스트 하기 좋은 구조가 된다.
trait ConfigSupport {
val config: Config = ConfigFactory.load()
}
object ConfigSupport extends ConfigSupport {
}
class NameService(configSupport: ConfigSupport) {
def printName(): String = {
configSupport.config.getString("domain")
}
}
class NameServiceTest extends FunSpec {
describe("NameServiceTest") {
it("The name should be kaven") {
val configSupport = mock[ConfigSupport]
val config = mock[Config]
when(configSupport.config).thenReturn(config)
when(config.getString("domain")).thenReturn("kaven")
val service = new NameService(configSupport)
assert("kaven" === service.printName())
}
}
}
scalacheck와 scalatest 모두 scala에서 테스트를 위한 라이브러리이며,
scalacheck는 property based testing 이다.
아래 의존성을 추가하면 되며, 링크를 통해 자세한 내용 확인해보자.
// https://mvnrepository.com/artifact/org.scalacheck/scalacheck
testImplementation group: 'org.scalacheck', name: 'scalacheck_2.11', version: '1.15.2'
테스트 하기 위한 Input data를 직접 정의하지 않고, random test data를 generate 해준다.
테스트를 진행하기 위한 메서드를 아래와 같이 생성하였다.
case class User(username: String, age: Int)
trait UserDao {
def save(user: User): Boolean
}
class UserService(userDao: UserDao) {
def createUser(username: String, age: Int): Boolean = {
val user = User(username, age)
userDao.save(user)
}
}
ScalaCheckPropertyChecks trait를 상속받은 후 forAll 사용하면, 여러 random input data를
생성하여 검증해 준다.
import org.mockito.Mockito.{verify, when}
import org.scalacheck.Arbitrary.arbitrary
import org.scalatest.FunSpec
import org.scalatest.Matchers.{be, convertToAnyShouldWrapper}
import org.scalatestplus.mockito.MockitoSugar.mock
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
class UserServiceTest extends FunSpec with ScalaCheckPropertyChecks {
describe("UserServiceTest") {
it("User should be saved successfully") {
val mockUserDao = mock[UserDao]
val userService = new UserService(mockUserDao)
forAll(arbitrary[String], arbitrary[Int]) { (username, age) =>
when(mockUserDao.save(User(username, age))).thenReturn(true)
userService.createUser(username, age) should be (true)
verify(mockUserDao).save(User(username, age))
}
}
}
}
User를 직접 전달 할 수도 있으며, 아래와 같이 각 필드에 대해서 Gen 키워드를 통해서
random하게 생성해준다.
더 자세한 사용방법은 링크를 참고하자.
import org.scalacheck.{Arbitrary, Gen}
class UserServiceTest extends FunSpec with ScalaCheckPropertyChecks {
describe("UserServiceTest") {
it("User should be saved successfully") {
val mockUserDao = mock[UserDao]
val userService = new UserService(mockUserDao)
implicit val userArbitrary: Arbitrary[User] = Arbitrary(
for {
username <- Gen.alphaStr
age <- Gen.choose(0, 100)
} yield User(username, age)
)
forAll { user: User =>
when(mockUserDao.save(user)).thenReturn(true)
userService.createUser(user.username, user.age) should be (true)
verify(mockUserDao).save(user)
}
}
}
}
Output
generated user: User(,84)
generated user: User(IdCoJoac,19)
generated user: User(XeWYkqajuxeB,1)
generated user: User(s,4)
generated user: User(rIabCyVFEWoWKjcDCgtdsoeTkn,11)
generated user: User(RabkPHymk,48)
generated user: User(YkRtusKeJwJuoUSzMXhZk,6)
generated user: User(dCvDImrKBemzwgjvlFapenZaeFXmLdomYXcWUhvEVijRsZ,71)
generated user: User(HjhZeugzXuzKxlUmIamQpHdOyYaDMMMfNxmdPKcTgAvnD,18)
generated user: User(OLNVDZbaY,14)
Reference
https://www.baeldung.com/scala/scalacheck
https://www.scalatest.org/scaladoc/3.0.7/org/scalatest/FunSpec.html
https://www.scalatest.org/
https://alvinalexander.com/scala/scalatest-bdd-examples-describe-given-when-then-assert/