Upgrade to PRO for Only $50/Yearโ€”Limited-Time Offer! ๐Ÿ”ฅ

Android Testing Best Practices [ko]

Sa-ryong Kang
September 25, 2021

Android Testing Best Practicesย [ko]

Sa-ryong Kang

September 25, 2021
Tweet

More Decks by Sa-ryong Kang

Other Decks in Technology

Transcript

  1. Why should I write test code? โ€ข ์ฝ”๋”ฉ ์ƒ์‚ฐ์„ฑ ์ฆ๋Œ€:

    ํ™•์‹ ์„ ๊ฐ–๊ณ  ์‹œ์Šคํ…œ์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Œ โ€ข ํšจ์œจ์ ์œผ๋กœ ๋ฒ„๊ทธ๋ฅผ ์žก์„ ์ˆ˜ ์žˆ์Œ โ€ข ์ฝ”๋“œ ๋ณ€๊ฒฝ(ํŠนํžˆ refactoring)์„ ์‰ฝ๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Œ โ€ข modularํ•œ ์„ค๊ณ„์— ๋„์›€: single responsibility
  2. Why should we write test code? โ€ข QA doesnโ€™t scale

    โ€ข ํ˜‘์—…์„ ์œ„ํ•ด ๋ฐ˜๋“œ์‹œ ํ•„์š” โ—ฆ ์—ฌ๋ฆ„ํœด๊ฐ€ ์ค‘์— ๋‚ด ์ฝ”๋“œ์—์„œ ์žฅ์• ๊ฐ€ ๋‚œ๋‹ค๋ฉด? โ–ช or ๋ช‡ ๊ฐœ์›” ๋’ค์˜ ๋‚ด๊ฐ€ ๊ฐ‘์ž๊ธฐ ๋‚ด ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ณด๊ฒŒ ๋œ๋‹ค๋ฉด? โ—ฆ ํ˜‘์—…์„ ์ด‰์ง„: code owner๊ฐ€ ์•„๋‹ˆ๋”๋ผ๋„ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Œ โ–ช ๋ฌธ์„œ๋กœ์„œ์˜ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ. ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋งŒ ๋ณด๋ฉด ํŠน์ • ์‹œ์Šคํ…œ์˜ ๊ธฐ๋Šฅ๊ณผ ์˜๋„, ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ๋ฒ•์„ ๋‹จ๋ฒˆ์— ํŒŒ์•… ๊ฐ€๋Šฅ โ–ช ์›๋ž˜ ์˜๋„์™€ ์–ด๊ธ‹๋‚˜๊ฒŒ ์ˆ˜์ •ํ–ˆ๋‹ค๊ณ  ํ•ด๋„ ํ…Œ์ŠคํŠธ๊ฐ€ ์‹คํŒจํ•˜๋ฏ€๋กœ ๊ธˆ๋ฐฉ ์•Œ์•„์ฑŒ ์ˆ˜
  3. Google์˜ ํ…Œ์ŠคํŠธ ์ •์ฑ… โ€ข ๋†’์€ Test Coverage*: ์—ฌ๊ธฐ์„œ ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€๋Š”

    ์˜ค์ง small-sized test๋กœ๋งŒ ์ธก์ •ํ•จ โ€ข Beyoncรฉ Rule: โ€œIf you liked it, then you shoulda put a test on it.โ€ * ํ”„๋กœ๋•ํŠธ ๋ณ„๋กœ ์ฐจ์ด๊ฐ€ ์žˆ์Œ
  4. When to use real device for testing โ€ข DO -

    ์•ˆ์ •์„ฑ / ํ˜ธํ™˜์„ฑ ํ…Œ์ŠคํŠธ, ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ โ€ข DON'T - Unit tests: ํ•  ์ˆ˜ ์žˆ๋Š” ํ•œ JVM์—์„œ ์‹คํ–‰ํ•ด์•ผ ํ•จ. ๊ทธ๋ฆฌ๊ณ  ๋˜์ง€ ์•Š๋Š” ๊ฒƒ์€ simulator์—์„œ. ์‹ค ๊ธฐ๊ธฐ ํ…Œ์ŠคํŠธ๋ฅผ ํ•˜๊ธฐ ์ „์— ๋จผ์ € ์ด๋Ÿฐ ๊ฒƒ๋“ค์„ ๊ณ ๋ คํ•ด๋ณผ ๊ฒƒ โ—ฆ ๊ฒฉ๋ฆฌ๋œ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ ๊ตฌ์ถ• (Hermetic testing) โ—ฆ Protocol testing for client-server compatibility
  5. ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ฒ˜์Œ/๋‹ค์‹œ ํ•ด๋ณธ๋‹ค๋ฉด? - 1๋‹จ๊ณ„๋Š”.. โ€ข ์ž‘์€, ๋…๋ฆฝ์ ์ธ ๋ถ€๋ถ„๋ถ€ํ„ฐ

    ์‹œ์ž‘ โ—ฆ ์˜ˆ: ๊ณ„์‚ฐ/๋ณ€ํ™˜ ๋กœ์ง์„ ๊ฐ€์ง„ ํด๋ž˜์Šค โ—ฆ ์ฐธ๊ณ : ํ…Œ์ŠคํŠธ ์ฃผ๋„ ๊ฐœ๋ฐœ(TDD by Example) [link]
  6. 2๋‹จ๊ณ„: ์‹ค์ œ๋กœ (๋”) ๋„์›€์ด ๋˜๋Š” ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ โ€ข ์‹ค์ œ/์˜์‚ฌ

    ๋””๋ฒ„๊น… ๊ณผ์ •์—์„œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ ์šฉ โ—ฆ ๊ตฌ๊ธ€์˜ ๊ฒฝ์šฐ, ์žฅ์• ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด.. i. ๋ฌธ์ œ๊ฐ€ ๋œ PR์„ ์ฐพ์•„์„œ ๋กค๋ฐฑํ•œ๋‹ค ii. ๋ฌธ์ œ PR์— ์žฅ์• ๋ฅผ ์žฌํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑ โ†’ ๋ฌผ๋ก  ๊ทธ ํ…Œ์ŠคํŠธ๋Š” fail iii. ํ…Œ์ŠคํŠธ๊ฐ€ ์„ฑ๊ณตํ•˜๋„๋ก ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •
  7. ์‹ค์ œ์— ๊ฐ€๊นŒ์šด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ๋งŒ๋“ค๊ธฐ โ€ข ์˜์กด์„ฑ์€ ์–ด๋–ป๊ฒŒ ํ•ด๊ฒฐํ•˜๋‚˜? โ—ฆ

    ์˜ˆ: SQLite, REST/gRPC call โ—ฆ Real code > Fake >> Mock/Spy/Stub โ—ฆ 1์ˆœ์œ„: ์˜์กด์„ฑ ๊ด€๊ณ„์— ์žˆ๋Š” ์ง„์งœ ์ฝ”๋“œ๋ฅผ ์‚ฌ์šฉ - ์˜ˆ: in-memory DB โ–ช prefer realism over isolation โ—ฆ 2์ˆœ์œ„: ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์— ์˜ํ•ด ์ œ๊ณต๋˜๋Š” ํ‘œ์ค€ fake๋ฅผ ์‚ฌ์šฉ โ—ฆ 3์ˆœ์œ„: ์œ„์˜ ๋ฐฉ๋ฒ•์ด ๋ถˆ๊ฐ€ํ•  ๋•Œ, mock ์‚ฌ์šฉ โ—ฆ Hilt! - d.android.com/training/dependency-injection/hilt-testing
  8. Mocking Best Practice โ€ข type-safeํ•œ matcher๋ฅผ ํ™œ์šฉํ•  ๊ฒƒ (hamcrest, truth

    ๋“ฑ + built-in) โ€ข interaction๋ณด๋‹ค state๋ฅผ ์ฒดํฌํ•  ๊ฒƒ (appendix ์ฐธ์กฐ) โ€ข ํ•„์š”์‹œ shared code๋ฅผ ์ ์ ˆํžˆ ์‚ฌ์šฉํ•  ๊ฒƒ (appendix ์ฐธ์กฐ) โ€ข Android API๋ฅผ mockingํ•˜์ง€ ๋ง ๊ฒƒ โ—ฆ Robolectric! via androidx.test โ—ฆ Fragment ๋…๋ฆฝ ์ƒ์„ฑ, Life Cycle ์ œ์–ด ๋“ฑ ๋งŽ์€ ๊ฐœ์„ ์ด ์žˆ์—ˆ์Œ
  9. 3๋‹จ๊ณ„: ํŒจ์ž ๋ถ€ํ™œ์ „! โ€ข ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์˜ ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ๋„ˆ๋ฌด ์–ด๋ ต๋‹ค โ€ข

    ์ด์ „์— ์ž˜ ๋Œ์•„๊ฐ”๋˜ ํ…Œ์ŠคํŠธ๊ฐ€ ์–ด๋Š ์ˆœ๊ฐ„ fail ๋œ๋‹ค โ€ข ์ด์œ ๋Š”? Brittle test
  10. ๋Œ€์›์น™: Unchanging Test โ€ข Test should not be changed by

    following reasons: โ—ฆ Pure refactorings โ—ฆ New features โ—ฆ Bug fixes โ€ข Exception: behavior changes
  11. Best Practice #1 Test via Public APIs fun processTransaction(transaction: Transaction)

    { if (isValid(transaction)) { saveToDatabase(transaction) } } private fun isValid(t: Transaction): Boolean = return t.amount < t.sender.balance private fun saveToDatabase(t: Transaction) { val s = "${t.sender}, ${t.recipient()}, ${t.amount()}" database.put(t.getId(), s) }
  12. fun processTransaction(transaction: Transaction) { if (isValid(transaction)) { saveToDatabase(transaction) } }

    private fun isValid(t: Transaction): Boolean = return t.amount < t.sender.balance private fun saveToDatabase(t: Transaction) { val s = "${t.sender}, ${t.recipient()}, ${t.amount()}" database.put(t.getId(), s) }
  13. Bad @Test fun emptyAccountShouldNotBeValid() { assertThat(processor.isValid(newTransaction().setSender(EMPTY_ACCOUNT))) .isFalse() } @Test fun

    shouldSaveSerializedData() { processor.saveToDatabase(newTransaction() .setId(123) .setSender("me") .setRecipient("you") .setAmount(100)) assertThat(database.get(123)).isEqualTo("me,you,100") }
  14. Good @Test fun shouldTransferFunds() { processor.setAccountBalance("me", 150) processor.setAccountBalance("you", 20) processor.processTransaction(newTransaction()

    .setSender("me") .setRecipient("you") .setAmount(100)) assertThat(processor.getAccountBalance("me")).isEqualTo(50) assertThat(processor.getAccountBalance("you")).isEqualTo(120) }
  15. Good @Test fun shouldNotPerformInvalidTransactions() { processor.setAccountBalance("me", 50) processor.setAccountBalance("you", 20) processor.processTransaction(newTransaction()

    .setSender("me") .setRecipient("you") .setAmount(100)) assertThat(processor.getAccountBalance("me")).isEqualTo(50) assertThat(processor.getAccountBalance("you")).isEqualTo(20) }
  16. Best Practice #3 Make Your Tests Complete and Concise //

    Bad @Test fun shouldPerformAddition() { val calculator = Calculator(RoundingStrategy(), "unused", ENABLE_COSINE_FEATURE, 0.01, calculusEngine, false) val result = calculator.calculate(newTestCalculation()) assertThat(result).isEqualTo(5) // Where did this number come from? }
  17. โ€ข โ€œํ…Œ์ŠคํŠธ ๋ณธ๋ฌธ์€, ํ…Œ์ŠคํŠธํ•˜๊ณ ์ž ํ•˜๋Š” ๊ฒƒ์„ ์ •ํ™•ํžˆ ์•Œ ์ˆ˜ ์žˆ๋Š”

    ์ •๋ณด๋ฅผ ๋ชจ๋‘ ๊ฐ–๊ณ  ์žˆ์–ด์•ผ ํ•˜๊ณ , ๋ฐ˜๋Œ€๋กœ ๋ถˆํ•„์š”ํ•œ ๋‚ด์šฉ์€ ๊ฐ์ถฐ์•ผ ํ•œ๋‹ค.โ€ โ—ฆ ๋” ์ž์„ธํ•œ ์„ค๋ช…: https://testing.googleblog.com/2014/03/testing-on-toilet-what-makes-good-test.html @Test fun shouldPerformAddition() { val calculator = newCalculator() val result = calculator.calculate(newCalculation(2, Operation.PLUS, 3)) assertThat(result).isEqualTo(5) } Best Practice #3 Make Your Tests Complete and Concise
  18. Best Practice #4 Test Behaviors, Not Methods // Code to

    be tested fun displayTransactionResults(user: User, transaction: Transaction) { ui.showMessage("You bought a " + transaction.itemName) if (user.balance < LOW_BALANCE_THRESHOLD) { ui.showMessage("Warning: your balance is low!") } }
  19. Bad: test methods @Test fun testDisplayTransactionResults() { transactionProcessor.displayTransactionResults( newUserWithBalance( LOW_BALANCE_THRESHOLD.plus(dollars(2))),

    Transaction("Some Item", dollars(3))) assertThat(ui.getText()).contains("You bought a Some Item") assertThat(ui.getText()).contains("your balance is low") }
  20. Good: test behaviors @Test fun displayTransactionResults_showsItemName() { transactionProcessor.displayTransactionResults( User(), Transaction("Some

    Item")) assertThat(ui.getText()).contains("You bought a Some Item") } @Test fun displayTransactionResults_showsLowBalanceWarning() { transactionProcessor.displayTransactionResults( newUserWithBalance( LOW_BALANCE_THRESHOLD.plus(dollars(2))), Transaction("Some Item", dollars(3))) assertThat(ui.getText()).contains("your balance is low") }
  21. Best Practice #6 Donโ€™t Put Logic in Tests // Bad

    @Test fun shouldNavigateToAlbumsPage() { val baseUrl = "http://photos.google.com/" val nav = Navigator(baseUrl) nav.goToAlbumPage() assertThat(nav.getCurrentUrl()).isEqualTo(baseUrl + "/albums") }
  22. Good @Test fun shouldNavigateToPhotosPage() { val nav = Navigator("http://photos.google.com/"); nav.goToPhotosPage()

    assertThat(nav.getCurrentUrl())) .isEqualTo("http://photos.google.com//albums") // Oops! }
  23. Best Practice #7 DAMP, Not DRY โ€ข DAMP: Descriptive And

    Meaningful Phrases โ€ข DRY: Don't Repeat Yourself
  24. Bad @Test fun shouldAllowMultipleUsers() { val users = createUsers(false, false)

    val forum = createForumAndRegisterUsers(users) validateForumAndUsers(forum, users) } @Test public void shouldNotAllowBannedUsers() { val users = createUsers(true) val forum = createForumAndRegisterUsers(users) validateForumAndUsers(forum, users) } // Lots more tests... private fun createUsers(boolean... banned): List<User> { // ... } // ...
  25. Good @Test fun shouldAllowMultipleUsers() { val user1 = newUser().setState(State.NORMAL).build() val

    user2 = newUser().setState(State.NORMAL).build() val forum = Forum() forum.register(user1) forum.register(user2) assertThat(forum.hasRegisteredUser(user1)).isTrue() assertThat(forum.hasRegisteredUser(user2)).isTrue() }
  26. Good @Test fun shouldNotRegisterBannedUsers() { val user = newUser().setState(State.BANNED).build() val

    forum = Forum() try { forum.register(user) } catch(ignored: BannedUserException) {} assertThat(forum.hasRegisteredUser(user)).isFalse() }
  27. References โ€ข ๋ฐœํ‘œ ์Šฌ๋ผ์ด๋“œ: https://speakerdeck.com/saryong โ€ข Software Engineering At Google

    โ—ฆ https://abseil.io/resources/swe_at_google.2.pdf โ€ข Test apps on Android โ—ฆ d.android.com/training/testing โ€ข Google Codelab - Android Testing Basics โ—ฆ d.android.com/codelabs/advanced-android-kotlin-training-testing-basics โ€ข Google Testing Blog: https://testing.googleblog.com/
  28. Best Practice #2 Test State, Not Interactions // Bad @Test

    fun shouldWriteToDatabase() { accounts.createUser("foobar") verify(database).put("foobar") }
  29. Best Practice #5 Structure tests to emphasize behaviors (Good) @Test

    fun transferFundsShouldMoveMoneyBetweenAccounts() { // Given two accounts with initial balances of $150 and $20 val account1 = newAccountWithBalance(usd(150)) val account2 = newAccountWithBalance(usd(20)) // When transferring $100 from the first to the second account bank.transferFunds(account1, account2, usd(100)) // Then the new account balances should reflect the transfer assertThat(account1.getBalance()).isEqualTo(usd(50)) assertThat(account2.getBalance()).isEqualTo(usd(120)) }
  30. Another good example @Test fun shouldTimeOutConnections() { // Given two

    users val user1 = newUser() val user2 = newUser() // And an empty connection pool with a 10-minute timeout val pool = newPool(Duration.minutes(10)) // When connecting both users to the pool pool.connect(user1) pool.connect(user2) // Then the pool should have two connections assertThat(pool.getConnections()).hasSize(2) // When waiting for 20 minutes clock.advance(Duration.minutes(20)) // Then the pool should have no connections assertThat(pool.getConnections()).isEmpty() // And each user should be disconnected assertThat(user1.isConnected()).isFalse() assertThat(user2.isConnected()).isFalse()
  31. Best Practice #8 No Shared Value // Bad private val

    ACCOUNT_1 = Account.newBuilder() .setState(AccountState.OPEN).setBalance(50).build() private val ACCOUNT_2 = Account.newBuilder() .setState(AccountState.CLOSED).setBalance(0).build() private val ITEM = Item.newBuilder() .setName("Cheeseburger").setPrice(100).build() // Hundreds of lines of other tests... @Test fun canBuyItem_returnsFalseForClosedAccounts() { assertThat(store.canBuyItem(ITEM, ACCOUNT_1)).isFalse() } // ...
  32. Good private fun newContact(): Contact.Builder = Contact.newBuilder() .setFirstName("Grace") .setLastName("Hopper") .setPhoneNumber("555-123-4567")

    @Test fun fullNameShouldCombineFirstAndLastNames() { val contact = newContact() .setFirstName("Ada") .setLastName("Lovelace") .build() assertThat(contact.getFullName()).isEqualTo("Ada Lovelace") }
  33. Best Practice #9 Shared Setup // Bad private lateinit var

    nameService: NameService private lateinit var userStore: UserStore @Before fun setUp() { nameService = NameService() nameService.set("user1", "Donald Knuth") userStore = UserStore(nameService) } // [... hundreds of lines of tests ...] @Test fun shouldReturnNameFromService() { val user = userStore.get("user1") assertThat(user.getName()).isEqualTo("Donald Knuth") }
  34. Good private lateinit nameService: NameService private lateinit userStore: UserStore @Before

    fun setUp() { nameService = NameService() nameService.set("user1", "Donald Knuth") userStore = UserStore(nameService) } @Test fun shouldReturnNameFromService() { nameService.set("user1", "Margaret Hamilton") val user = userStore.get("user1") assertThat(user.getName()).isEqualTo("Margaret Hamilton") }
  35. Good example of helper method fun assertUserHasAccessToAccount(user: User, account: Account)

    { for (long userId : account.getUsersWithAccess()) { if (user.id == userId) { return } } fail("${user.name} cannot access ${account.name}") }