Is Adopting Test Code Really Inefficient?

A real-world look at adopting test code.
DelightRoom's avatar
Feb 20, 2024
Is Adopting Test Code Really Inefficient?

A real-world look at adopting test code

I'm sure everyone has heard a lot about why test code is important.
Personally, I think there are 3 main reasons why test code is important.

Why test code is important

  1. Refactoring guarantee
    - You can verify that there are no side effects on existing features while refactoring.

  2. Role as documentation
    - It provides examples of how specific features should behave, making it easier for new engineers to understand the codebase.

  3. Bug detection
    - It helps you catch and fix bugs in the early stages of development.
    - While you can't verify every scenario, writing test code helps you identify and verify edge cases.

The difficulties of writing test code

However, writing test code is not easy. Why is that?

In my case, I struggled for the following reasons:

  1. Writing test code takes way too long.

  2. It's hard to determine the scope and details of the tests.
    2–1. How far should I test?
    2–2. What exactly should I test?

Because it was difficult, I kept putting off writing test code. As a result, I frequently ran into issues where I had to drastically overhaul the architecture whenever the product specs changed, or I ended up manually catching bugs while testing.

The turning point came from a desire to better achieve the Android team's OKRs. I started writing test code because I wanted to prevent crashes, and even if a crash did happen, I wanted to make sure it wouldn't happen again.

To build a stable product, our team settled on writing BDD-style test code. In this post, I want to share my experience writing test cases in a BDD format using Kotest, along with what I learned along the way.

What is BDD?

  • The full name is Behavior Driven Development. In short, development driven by behavior.

  • It's a method of writing test cases by constructing user scenarios based on user behaviors.

  • You can think of these user scenarios as the requirements for each feature.

The process of writing test code

There are many ways to write test code, but for this post, I'll use Kotest's BehaviorSpec as an example.

What is BehaviorSpec?

It follows a Given — When — Then format to describe the expected outcome (Then) when a specific action (When) is performed under a given set of conditions (Given).

  • Given: The context or environment (ex: Given a user is on the password change screen)

  • When: The action (ex: When they enter an invalid password)

  • Then: The expected result (ex: Then the change password button becomes disabled)

You can think of test code as reflecting the requirements from the product specs or user stories. The more detailed you express the possible cases from those requirements, the more diverse your test code becomes.

To give you an example in code, it looks something like this:

class ChangePasswordTest : BehaviorSpec() {
 Given("비밀번호를 변경하려는 유저가") {
       When("유효하지 않는 비밀번호를 입력하면") {
         Then("비밀번호 변경 버튼이 비활성화된다.") { 
        }
       }
     }
   }
}

A real-world project example

  • Let's take the wake-up check feature as an example.

  • Alarmy's wake-up check feature is considered 'complete when the user successfully dismisses their alarms for 5 consecutive days after starting the check'.

  • Previously, the completion criteria was 7 consecutive days, but to boost participation, it was changed to 5 days.

  • Let me show you an example of the test code I wrote when this completion criteria changed.

class WakeUpCheckChallengeViewModelTest : BehaviorSpec() {
 Given("유저가 출석체크 진행중이면") {
      val staredTime = LocalDateTime(2023, 1, 1, 8, 0)
      val fakeWakeUpCheckChallengeRepository = FakeWakeUpCheckChallengeRepository(
        wakeUpCheckChallenge = ChallengeCheck(
          status = ChallengeStatus.PARTICIPATING,
          startedTime = staredTime.toEpochMilliseconds(),
          dismissTime = 0L,
          successCount = 2
        ),
      )
      When("출석 체크의 성공 날짜 기준은") {
        val expectedSuccessCount = fakeWakeUpCheckChallengeRepository.getSuccessCount()
        Then("7이다") {
          expectedSuccessCount shouldBe 7
        }
      }

      When("출석 체크 실패 후") {
        fakeWakeUpCheckChallengeRepository.failed()
        Then("출석 체크 성공 횟수는 5로 초기화 된다.") {
          val expectedSuccessCount = fakeWakeUpCheckChallengeRepository.getSuccessCount()
          expectedSuccessCount shouldBe 5
        }
      }

    }
  }
}

Writing the test code above really made me appreciate the benefits of having it.

The benefits of test code

  • You can verify features without actually having to ring an alarm.

  • You can test edge cases.
    ex) What should the success criteria be for a user who had already progressed 5 or more days under the old rule?

If I hadn't written test code, I probably would have had to manually ring alarms every time to test the feature, or I would have discovered those edge cases far too late.

Conclusion

In the past, I shied away from test code based on the vague assumption that writing it would take too long and be inefficient.

But after actually getting my hands dirty with it, I truly realized why test code is so important.

  • Refactoring support: It gives you confidence when refactoring existing features.

  • Role as documentation: You can just look at the test code and instantly see, "Ah, the wake-up check completion criteria changed from 7 days to 5 days."

  • Bug detection: Writing tests forces you to think about exception cases early on, allowing you to check them quickly.

  • Pushing for testable architecture: Writing test code makes you think harder about creating a less dependent code structure.

There's an old proverb that says "more haste, less speed." I got to experience exactly that while writing test code, and I could feel our architecture becoming more flexible to change.

Rather than putting off test code altogether because you can't write perfect tests on the first try, it's far more important to just start writing them, think critically about them, and strive to write better ones along the way.

In short, continuous effort is far more important than refusing to pick up the shovel just because your first scoop might not be perfect.

If you're on the fence about adopting test code, I highly recommend defining exactly what your hesitations are, then writing just a little bit of test code to see if those worries are actually justified.

Share article