この記事について
この記事ではある事例から問題のあるテストコードのポイントを探り、どうしたらより良いテストコードとなるのかについて述べる。
この記事では事例とするコードに特定の言語・ツールを使用しているが、特有の記法にはコメントアウトによる補足を入れている。今回使用する言語・ツールでできることは他でも可能であるため、馴染みのある言語・ツールに変換して読んでいただきたい。
テストコードの事例
テストコードの対象となるクラスは以下のメソッドを持つ
- 購入情報(ユーザーID、店舗コード、商品コード、商品価格)を出力する
- 以下の条件の場合、商品価格の割引を行う
- ユーザーが会員である場合は割引
- 購入する店舗がセール中の場合は割引
コードは以下(コードはSpringBootとKotlinを使用)
data class PurchaseParam(
val userId: String,
val shopCode: String,
val productCode: String,
val purchaseCount: Int,
)
data class User(
val userId: String,
val isMemberShip: Boolean
)
data class Shop(
val shopCode: String,
val onSale: Boolean
)
data class Product(
val productCode: String,
val price: Int
)
data class PurchaseInfo(
val userId: String,
val shopCode: String,
val productCode: String,
val cost: Int
)
@Service
class PurchaseService(
// ユーザー情報リポジトリ
private val userRepository: UserRepository
// 店舗情報リポジトリ
private val shopRepository: ShopRepository
// 商品情報リポジトリ
private val productRepository: ProductRepository
) {
companion object {
// 会員ユーザーは5%引き
private const val MEMBER_DISCOUNT = 0.95
// 購入する店舗がセール中の場合は10%引き
private const val SHOP_SALE_DISCOUNT = 0.90
}
/**
* 購入情報を出力
* @param purchaseParam 商品購入情報
*/
fun outputPurchaseInfo(param: PurchaseParam): PurchaseInfo {
// ユーザー情報を取得
val user = userRepository.findByUserId(param.userId)
// 店舗情報を取得
val shop = shopRepository.findByShopCode(param.shopCode)
// 商品情報を取得
val product = productRepository.findByProductCode(param.productCode)
var discountedProductPrice = product.price
// ユーザーが会員である場合に価格は5%引きとなる(小数点は四捨五入)
if(user.isMemberShip) {
discountedProductPrice = (discountedProductPrice * MEMBER_DISCOUNT).roundToInt()
}
// 店舗がセール中の場合に価格は10%引きとなる(小数点は四捨五入)
if(user.isMemberShip) {
discountedProductPrice = (discountedProductPrice * SHOP_SALE_DISCOUNT).roundToInt()
}
val cost = discountedProductPrice * param.purchaseCount
// 購入情報を出力
return PurchaseInfo(
userId = user.userId,
shopCode = shop.shopCode,
productCode = product.productCode,
cost = cost
)
}
}
上記のクラスに対するテストケースは以下となる
- 正常に購入情報の出力ができるか
- ユーザーが会員である場合、価格に割引がされているか
- 店舗がセール中である場合、価格に割引がされているか
- ユーザーが会員かつ店舗がセール中である場合、両方の割引が適用されているか
では実際に上記をもとに問題のあるテストコードを実装する(JUnit5, MockKを使用)
@ExtendWith(MockKExtension::class)
class PurchaseServiceTest(){
@MockK
private lateinit var userRepository: UserRepository
@MockK
private lateinit var shopRepository: ShopRepository
@MockK
private lateinit var productRepository: ProductRepository
@InjectMockK
private lateinit var service: PurchaseService
// このクラスの共通データ
companion object {
private const val USER_ID = "USER_ID"
private const val SHOP_CODE = "SHOP_CODE"
private const val PRODUCT_CODE = "PRODUCT_CODE"
private val user = User(
userId = USER_ID,
isMembership = false
)
private val shop = Shop(
shopCode = SHOP_CODE,
onSale = false
)
private val product = Product(
productCode = PRODUCT_CODE,
price = 100
)
}
// ユーザー・店舗・商品情報取得メソッドの振る舞いを共通に定義する
@BeforeEach
fun setup() {
every { userRepository.findByUserId(USER_ID) } returns user
every { shopRepository.findByShopCode(SHOP_CODE) } returns shop
every { productRepository.findByProductCode(PRODUCT_CODE) } returns product
}
// 正常に購入情報の出力ができるか
@Test
fun shouldOutputPurchaseInformationSuccessfully(){
val param = PurchaseParam(
userId = USER_ID,
shopCode = SHOP_CODE,
productCode = PRODUCT_CODE,
purchaseCount = 10,
)
val actual = service.outputPurchaseInfo(param)
assertThat(actual.userId).isEqualTo(USER_ID)
assertThat(actual.shopCode).isEqualTo(SHOP_CODE)
assertThat(actual.productCode).isEqualTo(PRODUCT_CODE)
assertThat(actual.cost).isEqualTo(1000)
}
// ユーザーが会員である場合、価格に割引がされているか
@Test
fun shouldApplyDiscountWhenUserIsMember(){
val menbershipUser = User(
userId = USER_ID,
isMembership = true
)
// 会員ユーザーを取得するように上書き
every { userRepository.findByUserId(USER_ID) } returns menbershipUser
val param = PurchaseParam(
userId = USER_ID,
shopCode = SHOP_CODE,
productCode = PRODUCT_CODE,
purchaseCount = 10,
)
val actual = service.outputPurchaseInfo(param)
assertThat(actual.userId).isEqualTo(USER_ID)
assertThat(actual.shopCode).isEqualTo(SHOP_CODE)
assertThat(actual.productCode).isEqualTo(PRODUCT_CODE)
assertThat(actual.cost).isEqualTo(950)
}
// 店舗がセール中である場合、価格に割引がされているか
@Test
fun shouldApplyDiscountWhenStoreIsOnSale(){
val shopOnSale = Shop(
shopCode = SHOP_CODE,
onSale = true
)
// セール中の店舗を取得するように上書き
every { shopRepository.findByShopCode(SHOP_CODE) } returns shopOnSale
val param = PurchaseParam(
userId = USER_ID,
shopCode = SHOP_CODE,
productCode = PRODUCT_CODE,
purchaseCount = 10,
)
val actual = service.outputPurchaseInfo(param)
assertThat(actual.userId).isEqualTo(USER_ID)
assertThat(actual.shopCode).isEqualTo(SHOP_CODE)
assertThat(actual.productCode).isEqualTo(PRODUCT_CODE)
assertThat(actual.cost).isEqualTo(900)
}
// ユーザーが会員かつ店舗がセール中である場合、価格に割引がされているか
@Test
fun shouldApplyDiscountWhenUserIsMemberAndStoreIsOnSale(){
val menbershipUser = User(
userId = USER_ID,
isMembership = true
)
// 会員ユーザーを取得するように上書き
every { userRepository.findByUserId(USER_ID) } returns menbershipUser
val shopOnSale = Shop(
shopCode = SHOP_CODE,
onSale = true
)
// セール中の店舗を取得するように上書き
every { shopRepository.findByShopCode(SHOP_CODE) } returns shopOnSale
val param = PurchaseParam(
userId = USER_ID,
shopCode = SHOP_CODE,
productCode = PRODUCT_CODE,
purchaseCount = 10,
)
val actual = service.outputPurchaseInfo(param)
assertThat(actual.userId).isEqualTo(USER_ID)
assertThat(actual.shopCode).isEqualTo(SHOP_CODE)
assertThat(actual.productCode).isEqualTo(PRODUCT_CODE)
assertThat(actual.cost).isEqualTo(855)
}
}
上記のテストケースは必要なものをクラスの共通部分で用意し、各テストケースには変更必要な部分の記載のみであるため記述量を抑えられて見やすく感じるが、以下の問題がある
- 各テストケースのコードだけではテストの内容がわからない
- 例えばshouldOutputPurchaseInformationSuccessfullyを見ただけではユーザー・店舗・商品情報がどのようなものであるのか分からず、
@BeforeEachでの定義や共通データを参照しなくては完全に理解できない、他のテストコードでも同様
- 例えばshouldOutputPurchaseInformationSuccessfullyを見ただけではユーザー・店舗・商品情報がどのようなものであるのか分からず、
- 各テストケースのコードだけでは結果が正しいかがわからない
- テストの内容がわかりにくいためテストの結果が正しいのか分かりにくい(テストケースの誤った解釈を引き起こし、コード変更時にバグが混入する確率が高くなる)
上記の問題を解決策は、テストケースで使用するデータやメソッドの振る舞いを各テストケースで定義することである。
この方針のもとで書き直したテストコードは以下である
@ExtendWith(MockKExtension::class)
class PurchaseServiceTest(){
@MockK
private lateinit var userRepository: UserRepository
@MockK
private lateinit var shopRepository: ShopRepository
@MockK
private lateinit var productRepository: ProductRepository
@InjectMockK
private lateinit var service: PurchaseService
// 正常に購入情報の出力ができるか
@Test
fun shouldOutputPurchaseInformationSuccessfully(){
val userId = "USER_ID"
val shopCode = "SHOP_CODE"
val productCode = "PRODUCT_CODE"
val menbershipUser = User(
userId = userId,
isMembership = false
)
every { userRepository.findByUserId(userId) } returns menbershipUser
val shopOnSale = Shop(
shopCode = shopCode,
onSale = false
)
every { shopRepository.findByShopCode(shopCode) } returns shopOnSale
val product = Product(
productCode = productCode,
price = 100
)
every { productRepository.findByProductCode(productCode) } returns product
val param = PurchaseParam(
userId = userId,
shopCode = shopCode,
productCode = productCode,
purchaseCount = 10,
)
val actual = service.outputPurchaseInfo(param)
assertThat(actual.userId).isEqualTo(userId)
assertThat(actual.shopCode).isEqualTo(shopCode)
assertThat(actual.productCode).isEqualTo(productCode)
assertThat(actual.cost).isEqualTo(1000)
}
// ユーザーが会員である場合、価格に割引がされているか
@Test
fun shouldApplyDiscountWhenUserIsMember(){
val userId = "USER_ID"
val shopCode = "SHOP_CODE"
val productCode = "PRODUCT_CODE"
val menbershipUser = User(
userId = userId,
isMembership = true
)
every { userRepository.findByUserId(userId) } returns menbershipUser
val shopOnSale = Shop(
shopCode = shopCode,
onSale = false
)
every { shopRepository.findByShopCode(shopCode) } returns shopOnSale
val product = Product(
productCode = productCode,
price = 100
)
every { productRepository.findByProductCode(productCode) } returns product
val param = PurchaseParam(
userId = userId,
shopCode = shopCode,
productCode = productCode,
purchaseCount = 10,
)
val actual = service.outputPurchaseInfo(param)
assertThat(actual.userId).isEqualTo(userId)
assertThat(actual.shopCode).isEqualTo(shopCode)
assertThat(actual.productCode).isEqualTo(productCode)
assertThat(actual.cost).isEqualTo(950)
}
// 店舗がセール中である場合、価格に割引がされているか
@Test
fun shouldApplyDiscountWhenStoreIsOnSale(){
val userId = "USER_ID"
val shopCode = "SHOP_CODE"
val productCode = "PRODUCT_CODE"
val menbershipUser = User(
userId = userId,
isMembership = false
)
every { userRepository.findByUserId(userId) } returns menbershipUser
val shopOnSale = Shop(
shopCode = shopCode,
onSale = true
)
every { shopRepository.findByShopCode(shopCode) } returns shopOnSale
val product = Product(
productCode = productCode,
price = 100
)
every { productRepository.findByProductCode(productCode) } returns product
val param = PurchaseParam(
userId = userId,
shopCode = shopCode,
productCode = productCode,
purchaseCount = 10,
)
val actual = service.outputPurchaseInfo(param)
assertThat(actual.userId).isEqualTo(userId)
assertThat(actual.shopCode).isEqualTo(shopCode)
assertThat(actual.productCode).isEqualTo(productCode)
assertThat(actual.cost).isEqualTo(900)
}
// ユーザーが会員かつ店舗がセール中である場合、価格に割引がされているか
@Test
fun shouldApplyDiscountWhenUserIsMemberAndStoreIsOnSale(){
val userId = "USER_ID"
val shopCode = "SHOP_CODE"
val productCode = "PRODUCT_CODE"
val menbershipUser = User(
userId = userId,
isMembership = true
)
every { userRepository.findByUserId(userId) } returns menbershipUser
val shopOnSale = Shop(
shopCode = shopCode,
onSale = true
)
every { shopRepository.findByShopCode(shopCode) } returns shopOnSale
val product = Product(
productCode = productCode,
price = 100
)
every { productRepository.findByProductCode(productCode) } returns product
val param = PurchaseParam(
userId = USER_ID,
shopCode = SHOP_CODE,
productCode = PRODUCT_CODE,
purchaseCount = 10,
)
val actual = service.outputPurchaseInfo(param)
assertThat(actual.userId).isEqualTo(USER_ID)
assertThat(actual.shopCode).isEqualTo(SHOP_CODE)
assertThat(actual.productCode).isEqualTo(PRODUCT_CODE)
assertThat(actual.cost).isEqualTo(855)
}
}
それぞれのテストケースでデータ・メソッドの振る舞いを定義したことで全体のコード量は多くなったが、テストケースのコードを見るだけで何をしているのかがわかるようになり、各テストケースの結果の正しさの判定もしやすくなったので、今後のコードの変更が容易になった。
最後に
各テストケースの記述を減らす目的で必要なデータやメソッドの振る舞いを共通に定義する手法を採用するテストコードを良く見かけるが、そのようなコードはテストケースが不明確であるためその後のリファクタリングの妨げとなることが多い。
またもし共通部分で定義しないとテストコードが膨大となる場合、テスト対象のクラスは責務が曖昧で複雑なロジックとなっている可能性が高い。
クラスの責務を適切に保ち、リファクタリングを容易にするためにもテストコードは各テストケースで完結させるようにするべきではないだろうか?