4 min read

Antara Saya, Test, dan Mock

Antara Saya, Test, dan Mock
Photo by David Travis / Unsplash
Disclaimer: Tulisan ini bukan tulisan tutorial, ini refleksi bagaimana saya salah mengerti kebutuhan mock dalam testing.

Satu hal dalam testing yang dulu bikin saya mengganjal adalah mock, itu semua terjadi karena saya salah mengerti maksud dari mock itu sendiri, saya terlalu terfokus kepada mock response, saya berpikir saat itu "apa bedanya hardoced response dengan mock?". Selain tentang response yang sering disinggung saat belajar dan bertanya biasanya lebih berfokus kepada "agar bisa ngetes third party tanpa memanggil yang sebenarnya". Dua hal itu jadi membuat saya bingung.

Setelah di kantor mewajibkan test dan juga sering mengunakan mock, yang awalnya ikut-ikutan sekarang mulai sedikit ngerti dan menjawab kebingungan.

Third Party Call

Salah satu yang sering dibahas adalah tadi, dengan mock jika dalam satu fungsi kita memanggil third party maka ada kemungkinan test kita jadi lama dan juga tergantung si third party-nya. Contoh paling sederhana adala fitur forgot password.

Semua contoh di sini menggunakan python, tapi secara konsep seharusnya di bahasa apapun sama saja.
#main.py
from third_party import send_otp
from common import Result

userPhone = {"john": "081234567", "doe": "0897654422"}


def forgot_password(username: str) -> Result:
    # Change to db or other function to get user
    if username not in userPhone:
        return {"success": False, "message": "User not found"}

    phoneNumber = userPhone[username]
    res = send_otp(phoneNumber)

    return res

lalu ini fungsi third party-nya untuk mengirim otp

# third_party.py
from common import Result
from random import randint
from time import sleep


def send_otp(number: str) -> Result:
    # Simulate intermitten long running process
    delay = randint(0, 5)
    sleep(delay)
    return {"success": True, "message": ""}

Lalu ini test yang saya buat.

#test.py
import unittest
from main import forgot_password


class TestForgotPassword(unittest.TestCase):
    def test_forgot_password_success(self):
        # input
        username = "john"
        # expected
        expected = {"success": True, "message": ""}
        result = forgot_password(username)
        self.assertEqual(result, expected)

Saat melakukan test hasilnya akan seperti ini

➜  mock python -m unittest test.TestForgotPassword
.
----------------------------------------------------------------------
Ran 1 test in 4.001s

OK
➜  mock python -m unittest test.TestForgotPassword
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Test nya jadi susah, kadang lama kadang sebentar.

Karena seperti yang dibilang tadi agar testing ini tidak terpengaruh maka si third party ini perlu dimock.

import unittest
from main import forgot_password

from unittest.mock import patch


class TestForgotPassword(unittest.TestCase):
    @patch("main.send_otp")
    def test_forgot_password_success(self, mock):
        # input
        username = "john"

        # expected
        expected = {"success": True, "message": ""}
        expectedMock = {"success": True, "message": ""}

        mock.return_value = expectedMock

        result = forgot_password(username)
        self.assertEqual(result, expected)

Hasilnya

➜  mock python -m unittest test.TestForgotPassword
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
➜  mock python -m unittest test.TestForgotPassword
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Flow Function

Pada contoh di atas memang menjawab menghilangkan ketergantungan dari third party, tapi dulu saya merasa kalau hanya mengubah response yang diharapkan tidak otomatis jadi benar kan, maksud dari tidak otomatis benar adalah jika ada yang mengubah kode forgot_password jadi seperti di bawah ini

def forgot_password(username: str) -> Result:
    # Change to db or other function to get user
    # Default response
    res = {"success": True, "message": ""}
    if username not in userPhone:
        return {"success": False, "message": "User not found"}

    phoneNumber = userPhone[username]

    return res

Saat kita jalankan testnya masih akan sukses

➜  mock python -m unittest test.TestForgotPassword
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
➜  mock python -m unittest test.TestForgotPassword
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Kenapa sukses? Padahal fungsi send_otp yang penting hilang? Jadi yang lupa password tidak akan dikirimkan otp? Salah dong testnya? Di sini yang saya maksud bahwa jika  yang dibicarakan adalah hanya "mengubah respon" dan "tidak tergantung dengan third party" itu membuat pertanyaan "jadi mock buat apa?"

Sehingga balik lagi, yang harus dites kan bukan si "mock" nya tapi fitur itu sendiri:

  1. Forgot password kalau sukses wajib memanggil send_otp.
  2. Forgot password kalau tidak menemukan user jadi false, dan tidak manggil send_otp
  3. Karena yang diinput adalah username sedangkan send_otp itu phone number, apakah benar kita mengirimkan phone number ke send_otp?

Sehingga kalau sudah fokus ke fungsi utamanya sendiri maka ada perubahan di testingnya

import unittest
from main import forgot_password

from unittest.mock import patch


class TestForgotPassword(unittest.TestCase):
    @patch("main.send_otp")
    def test_forgot_password_success(self, mock):
        # input
        username = "john"

        # expected
        expected = {"success": True, "message": ""}
        expectedMock = {"success": True, "message": ""}
        phoneNumber = "081234567"

        # mock the response
        mock.return_value = expectedMock

        result = forgot_password(username)

        totalCall = mock.call_count
        mock.assert_called_with(phoneNumber)
        self.assertEqual(result, expected)
        self.assertEqual(1, totalCall)

Sehingga ketika melakukan test ulang mendapatkan pesan

AssertionError: expected call not found.
Expected: send_otp('081234567')
  Actual: not called.

Lalu kembalikan fungsi forgot_password seperti semula

def forgot_password(username: str) -> Result:
    # Change to db or other function to get user
    res = {"success": True, "message": ""}
    if username not in userPhone:
        return {"success": False, "message": "User not found"}

    phoneNumber = userPhone[username]
    res = send_otp(phoneNumber)

    return res

Jalankan testnya lagi

➜  mock python -m unittest test.TestForgotPassword
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
➜  mock python -m unittest test.TestForgotPassword
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

Perubahan tesnya kan hanya sedikit tapi cukup membuat tenang.

mock.assert_called_with(phoneNumber): Memastikan bahwa fungsi nya menerima inputan sesuai yang diharapkan

totalCall = mock.call_count dan self.assertEqual(1, totalCall): Memastikan bahwa fungsi send_otp hanya dipanggil sekali.

Dengan tambahan ini saat buat skenario gagal bisa seperti berikut

import unittest
from main import forgot_password

from unittest.mock import patch


class TestForgotPassword(unittest.TestCase):
    @patch("main.send_otp")
    def test_forgot_password_success(self, mock):
        # input
        username = "john"
        phoneNumber = "081234567"

        # expected
        expected = {"success": True, "message": ""}
        expectedMock = {"success": True, "message": ""}

        # mock the response

        mock.return_value = expectedMock

        result = forgot_password(username)

        totalCall = mock.call_count
        mock.assert_called_with(phoneNumber)
        self.assertEqual(result, expected)
        self.assertEqual(1, totalCall)

    @patch("main.send_otp")
    def test_forgot_password_false(self, mock):
        # input
        username = "johnx"

        # expected
        expected = {"success": False, "message": "User not found"}
        expectedMock = {"success": True, "message": ""}

        # mock the response

        mock.return_value = expectedMock

        result = forgot_password(username)

        totalCall = mock.call_count
        self.assertEqual(result, expected)
        self.assertEqual(0, totalCall)

Di contoh yang gagal ada bagian ini self.assertEqual(0, totalCall) ini membantu saya yakin bahwa jika terjadi kesalahan saat tidak menemukan user maka tidak akan manggil send_otp.

➜  mock python -m unittest test.TestForgotPassword
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

Jadi kesalahan saya dalam belajar mock saat itu adalah:

  1. Terlalu fokus kepada objek yang di-mock bukan kepada fungsi yang akan di-test.
  2. Fokus mock justru bukan sekadar di response tapi apakah fungsinya terpanggil dan apakah parameter yang dikirim itu sesuai atau tidak