Membuat REST API Dengan Prinsip Test-Driven Development Yang (semoga) Benar

Membuat paradigma code berdasarkan test-driven development

Hudya
11 min readNov 15, 2020

Halo semua, Kiddy disini dan pada kesempatan kali ini saya pengen berbagi insight mengenai cara membuat REST API dengan prinsip Test-Driven Development atau biasa disebut dengan TDD yang semoga saja benar.

Nah di tutorial kali ini kita akan ngoding menggunakan FastAPI Python, since saya udah kenal sama ini framework, saya mulai move on dari leluhurnya yaitu Flask kecuali kebutuhan kantor saja hehehe.

Nah bagi kamu yang tidak menggunakan FastAPI Python, ya silahkan disesuaikan kembali sesuai ajaran dan dokumentasi yang telah dituliskan pada kitab framework masing-masing.

Eh tapi sebelum kita melanjutkan tutorial ini ada baiknya bagi kamu yang belum mudeng sama TDD untuk belajar dulu pengertian dan teori serta how we do it the TDD itu sendiri yah, silahkan mampir di artikel ini.

Oke sekarang seperti biasa kita buat dulu projeknya, saya ngga perlu basa-basi lagi ya ngajarin kalian cara ngebuat virtual environment hingga tetek bengeknya, silahkan belajar sendiri dulu karena tutorial ini ditujukan untuk yang advance dan bukan newbie.

Jangan lupa ya gaes, tutorial ini menggunakan MongoDB, jadi pastikan kalian punya MongoDB dulu.

Sebagian yang saya tulis disini sudah saya tulis juga pada tutorial dibawah ini, jadi bagi yang pertama kali pake FastAPI Python sekiranya mampir dulu ke tutorial dibawah ini ya gan.

Kalau sudah mantap dan yakin silahkan clone github saya biar cepet.

Seperti biasa jangan lupa aktifkan venv dan install dependensinya.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pip install -r requirements.txt

Oke kalo udah diinstall requirementsnya kita lanjut.

Ubah dulu isi file models/user.py

class Users(Document):
name = StringField(max_length=200, required=True)
email = EmailField(required=True)

Sekarang hapus isi file controller/UserController.py agar diubah menjadi sedikit saja, langsung coppas aja kode dibawah dan ditiban ke milik kalian.

from starlette.requests import Request
from starlette.responses import JSONResponse
from app import response
from app.models.user import Users
from app.transformers import UserTransformer
class UserController:
@staticmethod
async def index(request: Request) -> JSONResponse:
pass

“Hah serius bang?”

Iye, udah ikutin aja dulu.

Sekarang pindah ke routers/users.py dan copy paste code dibawah ini:

from fastapi import APIRouter
from starlette.requests import Request
from app.controllers.UserController import UserController as controller
router = APIRouter()

Jadi kita kosongin juga si routers/users.py-nya.

“Kok dikosongin bang?”

Jangan banyak ngemeng dulu, lakuin ajah dulu.

Sekarang giliran file tests/test_user.py yang kita kosongin, timpa aja pake kode dibawah ini.

from unittest import TestCaseimport json
from bson import ObjectId
from mongoengine import connect, disconnect
from starlette.testclient import TestClient
from app import app
from app.models.user import Users
"""
Inisialisasi Test Client sehingga menjalankan FastAPI tanpa perlu dijalankan dengan uvicorn.
"""
client = TestClient(app)
class TestUser(TestCase):@classmethod
def setUpClass(cls):
"""
setUp function sama seperti __init__ dimana file test pertama kali djialankan.
"""
disconnect()
connect('mongoenginetest', host='mongomock://localhost/mocking_db')
@classmethod
def tearDownClass(cls):
"""
tearDown function akan dijalankan saat seluruh test selesai dijalankan.
"""
disconnect()

Oke jadi kenapa saya minta hapus ini hapus itu macam gaya betol aja saya ini, alasannya adalah karena saya malas untuk membuat projek dari awal dan pake projek yang udah ada aja, jadi untuk membuat seolah kita ngoding dari awal kita hapus-hapus aja file yang akan jadi target tujuan kita.

Untuk file seperti TodoController dan lainnya yang berhubungan sama todo diemin atau hapus aja kalo kalian mau.

Oke sekarang kita udah punya satu file test unit yang akan jadi patokan TDD, dan controller beserta routernya, bersiap ya kita mulai.

Kita buat test pertama kita.

def test_user_url_get(self):
response = client.get("/users")
assert response.status_code == 200

kita akan membuat test untuk melakukan get ke route users.

“Eh bang tapi kan route users belom dibuat?”

Sabar aje lah tong, ikutin dulu.

Oke kita jalanin test kita yah, kalo masih ada yang protes bakalan error gue gebuk ye, emang pasti error, tenang aja.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
================================================================== test session starts ===================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 1 item
tests/test_user.py F [100%]======================================================================== FAILURES ========================================================================
_______________________________________________________________ TestUser.test_user_url_get _______________________________________________________________
self = <tests.test_user.TestUser testMethod=test_user_url_get>def test_user_url_get(self):
response = client.get("/users")
> assert response.status_code == 200
E AssertionError: assert 404 == 200
E + where 404 = <Response [404]>.status_code
tests/test_user.py:37: AssertionError
================================================================ short test summary info =================================================================
FAILED tests/test_user.py::TestUser::test_user_url_get - AssertionError: assert 404 == 200
=================================================================== 1 failed in 0.59s ====================================================================

Yeay failure, ngga usah sedih ataupun takut, kan wajar aja emang routesnya belom ada. Justru kalo misalnya 200 harusnya kalian bingung, karena ngga ada routesnya kok malah 200.

Sekarang kita ke routers/users.py, dan ubah agar nambah si method getnya.

from fastapi import APIRouter
from starlette.requests import Request
from app.controllers.UserController import UserController as controllerrouter = APIRouter()@router.get("", tags=["users"])
async def action(request: Request):
return await controller.index(request)

Sekarang jalanin lagi, pasti berhasil.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
================================================================== test session starts ===================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 1 item
tests/test_user.py . [100%]=================================================================== 1 passed in 0.46s ====================================================================

Nah bener kan berhasil (ya iyalah bang sekarang kan baru ada).

Oke lanjut sekarang kita buat test kedua.

def test_user_url_get_with_json(self):
response = client.get("/users")
assert response.status_code == 200

response = response.text
response = json.loads(response)

assert 'values' in response
assert 'message' in response

Sekarang kita akan menguji coba url test dengan melakukan hit terhadap response api dari method get, namun kita ada assertion harus ada key values didalam response tersebut.

Jalanin aja langsung skuy.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
================================================================== test session starts ===================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 2 items
tests/test_user.py .F [100%]======================================================================== FAILURES ========================================================================
__________________________________________________________ TestUser.test_user_url_get_with_json __________________________________________________________
self = <tests.test_user.TestUser testMethod=test_user_url_get_with_json>def test_user_url_get_with_json(self):
response = client.get("/users")
assert response.status_code == 200

response = response.text
response = json.loads(response)

> assert 'values' in response
E TypeError: argument of type 'NoneType' is not iterable
tests/test_user.py:46: TypeError
================================================================ short test summary info =================================================================
FAILED tests/test_user.py::TestUser::test_user_url_get_with_json - TypeError: argument of type 'NoneType' is not iterable
============================================================== 1 failed, 1 passed in 0.54s ===============================================================

Sekarang test kedua error, soalnya saya ngga mengembalikan values didalam response, yaudah kita balik lagi ke controller dan ubah cara kita melakukannya.

class UserController:
@staticmethod
async def index(request: Request) -> JSONResponse:
return response.ok('', 'Yeay!')

Bagi yang sudah mengikuti tutorial saya pasti ngga heran ada response.ok, yaitu sebuah file yang khusus menampung kembalian json.

Sekarang kita jalankan kembali.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
================================================================== test session starts ===================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 2 items
tests/test_user.py .. [100%]=================================================================== 2 passed in 0.53s ====================================================================

Nah berhasil kan, gampang toh? hahaha, sekarang kita mulai ke bagian get dengan ID.

def test_user_url_get_with_id(self):
id = "1"
response = client.get(f"/users/{id}")
assert response.status_code == 200

Sekarang kita jalankan, pasti error, santai aja.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
================================================================== test session starts ===================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 3 items
tests/test_user.py .F. [100%]======================================================================== FAILURES ========================================================================
___________________________________________________________ TestUser.test_user_url_get_with_id ___________________________________________________________
self = <tests.test_user.TestUser testMethod=test_user_url_get_with_id>def test_user_url_get_with_id(self):
id = "1"
response = client.get(f"/users/{id}")
> assert response.status_code == 200
E AssertionError: assert 404 == 200
E + where 404 = <Response [404]>.status_code
tests/test_user.py:52: AssertionError
================================================================ short test summary info =================================================================
FAILED tests/test_user.py::TestUser::test_user_url_get_with_id - AssertionError: assert 404 == 200
============================================================== 1 failed, 2 passed in 0.53s ===============================================================

Tambahin satu fungsi di controllers/UserController.py

@staticmethod
async def show(id) -> JSONResponse:
return response.ok('', 'Yeay')

Sekarang menuju routers/users.py dan tambahkan route baru

@router.get("/{id}", tags=["users"])
async def action(id: str):
return await controller.show(id)

Sekarang jalankan test lagi, ya pasti berhasil dong.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
================================================================== test session starts ===================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 3 items
tests/test_user.py ... [100%]=================================================================== 3 passed in 0.48s ====================================================================

Sekarang kita test kalo misalnya id tersebut tidak ada.

“Loh gimana caranya bang? Kan belum ada database”

Tujuan dari TDD adalah mengerjakan kode berdasarkan assert atau ekspektasi dari test, maka kalo test bilang dia mau get user dengan id 200 tapi belom ada koneksi ke DB, maka kita harus berpura-pura membuat controller seolah-olah terkoneksi ke database. Caranya gimana? Check it out.

Buat sebuah test baru, lalu jlanin testnya.

def test_user_get_with_fake_id(self):
id = "123456789"
response = client.get(f"/users/{id}")
assert response.status_code == 400

“Tapi bang nanti kan tetep 200 kembaliannya”

Haduh tong ikutin aja dulu.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
================================================================== test session starts ===================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 4 items
tests/test_user.py F... [100%]======================================================================== FAILURES ========================================================================
__________________________________________________________ TestUser.test_user_get_with_fake_id ___________________________________________________________
self = <tests.test_user.TestUser testMethod=test_user_get_with_fake_id>def test_user_get_with_fake_id(self):
id = "123456789"
response = client.get(f"/users/{id}")
> assert response.status_code == 400
E AssertionError: assert 200 == 400
E + where 200 = <Response [200]>.status_code
tests/test_user.py:57: AssertionError
================================================================ short test summary info =================================================================
FAILED tests/test_user.py::TestUser::test_user_get_with_fake_id - AssertionError: assert 200 == 400
============================================================== 1 failed, 3 passed in 0.59s ===============================================================

Tentu saja akan error, karena kita assert 400, tapi kembalian aslinya 400. Gampang, caranya kita balik ke controller UserController method show, ganti dengan kode sebagai berikut:

@staticmethod
async def show(id) -> JSONResponse:
if id == '123456789':
return response.badRequest('', 'Not Found!')

return response.ok('', 'Yeay')

Jalankan testnya dan kamu tentu saja akan berhasil.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
================================================================== test session starts ===================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 4 items
tests/test_user.py .... [100%]=================================================================== 4 passed in 0.49s ====================================================================

Nah gampang banget kan? Intinya kita tinggal membuat code untuk memenuhi ekspektasi si test meskipun kita belum terhubung di database, proses refactoringnya adalah setelah semua test unit terpenuhi.

Sekarang kita lanjut ke bagian post.

def test_user_url_create(self):
response = client.post("/users", json={"name": "Hudya", "email": "hudya@hey.com"})
assert response.status_code == 200

Jalankan, pasti error.

Sekarang kita buat method store di controller/UserController.py

@staticmethod
async def store(request: Request) -> JSONResponse:
body = await request.json()
name = body['name']
email = body['email']

return response.ok('', {
"name": name,
"email": email
})

Lalu kita tambahkan routers/users.py

@router.post("", tags=["users"])
async def action(request: Request):
return await controller.store(request)

Kita jalankan lagi, pasti berhasil.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
======================================================================== test session starts ========================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 5 items
tests/test_user.py ..... [100%]========================================================================= 5 passed in 0.48s =========================================================================

Gimana kalo kita ngetes kalau kita lupa masukkin json? Testnya begini, coba jalanin, pasti error karena kita belom pake try catch.

def test_user_url_create_without_json(self):
response = client.post("/users")
assert response.status_code == 400

Cara mengatasinya? pasang aja try catch sekalian.

@staticmethod
async def store(request: Request) -> JSONResponse:
try:
body = await request.json()
name = body['name']
email = body['email']

return response.ok('', {
"name": name,
"email": email
})
except Exception as e:
return response.badRequest('', f'{e}')

Gimana kalo misalnya kita lupa ngirim parameter nama?

def test_user_url_create_without_json_key_name(self):
response = client.post("/users", json={"email": "hudya@hey.com"})
assert response.status_code == 400

Jalankan, pasti tidak akan error karena kita sudah pasang try catch hehehe.

Bagaimana kalo misalnya kita kirim parameter email tapi email tersebut sudah ada di database? Padahal kita kan belom terkoneksi, tapi kita harus mocking membuat seolah-olah itu terjadi beneran.

Nah untuk kasus ini agak tricky, kita bisa menggunakan email lain seperti hudya2@hey.com lalu membuat kondisi di controller yang mengecek apabila hudya2@hey.com maka kita kembalikan 400, namun apabila sudah direfactor tentu tidak akan bisa, maka kalian punya dua opsi:

  1. Tunggu hingga semua test dasar selesai lalu mulai mengerjakan test yang hubungannya dengan database
  2. Mulai menyentuh database saat disini.

Opsi nomor 1 sangat baik bagi yang belum terbiasa merefactor code dan memiliki logic yang masih agak lemah, meskipun secara konsep TDD tidak baik.

Opsi nomor 2 sangat dikedepankan untuk konsistensi secara konsep TDD, namun siap-siap saja banyak code yang berantakan dan kamu harus siap refactor code-code berantakan itu.

def test_user_url_create_with_exist_data(self):
model = Users()
model.name = "Hudya"
model.email = "hudya2@hey.com"
model.save()

response = client.post("/users", json={"name": "Hudya", "email": "hudya2@hey.com"})
assert response.status_code == 400

Sekarang jalankan, pasti error karena kita ekspektasikan 400 tapi dikembaliin 200. Loh iya lah, kan emang belom diatur di controller. Sekarang kita ubah controllernya.

@staticmethod
async def store(request: Request) -> JSONResponse:
try:
body = await request.json()
name = body['name']
email = body['email']

user = Users.objects(email=email).first()

if user:
raise Exception("Email sudah didaftarkan!")

return response.ok('', {
"name": name,
"email": email
})
except Exception as e:
return response.badRequest('', f'{e}')

Sekarang jalankan testnya kembali.

(venv) kiddy@elementary-os:~/code/python/restful-fastapi-mongo$ pytest
======================================================================== test session starts ========================================================================
platform linux -- Python 3.8.6, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/kiddy/code/python/restful-fastapi-mongo
collected 8 items
tests/test_user.py ........ [100%]========================================================================= 8 passed in 0.50s =========================================================================

Voila, passed!

Nah itu dia sebagian contoh cara melakukan TDD yang tepat, meskipun ngga ada sih standar yang bener-bener harusnya tepat, cuma ada panduannya yang diharapkan kita bisa mempunyai standar seperti itu.

Tidak perlu merasa terbebani dengan standar yang ada, cukup ikuti saja baik tepat maupun tidak tepat, apabila di kantor kalian belum atau bahkan tidak menerapkan TDD yang kurang tepat, jangan langsung kamu merasa bahwa yang dilakukan itu salah, lakukan pendekatan verbal, ajak bicara baik-baik senior engineer kamu, beri pandangan yang lain, dan jangan seperti menggurui. Diskusi itu bukan menggurui, tapi berbagi cara melalui kacamata yang lain.

Ohiya, mohon maaf saya ngga bisa buat semua versi TDD pada artikel ini karena kalo saya tulis semuanya step by step pastilah panjang sekali tutorial ini, tapi saya harapkan dari beberapa gambaran yang saya jelaskan, kalian menjadi paham cara mengerjakan TDD yang baik.

Jadi ingat selalu konsep TDD itu adalah developer mencoba melewati test agar berhasil, jadi bukan konsep koding lalu tes, tes dulu, error, baru perbaiki error yang ada dari test tersebut pada kode, bisa langsung dengan menggabungkannya dengan model maupun tidak, kalian sang nahkoda, maka keputusan kemana berlayar ada di tangan kalian.

Tutorial ini hanya membagikan cara melakukan TDD sebelum benar-benar menyentuh database terlebih dahulu, karena sempat saya pribadi juga berpikir harusnya dikoding terlebih dahulu secara keseluruhan namun akhirnya ada yang membagikan saya insight yang masuk akal dan cara membuatnya.

Sehingga kita tidak perlu koding terlebih dahulu dan kita bisa mocking si test dengan memberikan respon seolah-olah memenuhi permintaan test padahal kita belum sambungkan ke database.

Kelemahan TDD

Test-Driven Development tidak cocok dikerjakan pada projek yang bersifat rapid development alias pengerjaan secara cepat seperti membuat candi untuk roro jonggrang 😆. Menulis test memakan waktu yang tidak sedikit dan engineer perlu berpikir kreatif tentang test apa saja yang perlu dibuat, hal ini menguji logic programmer terutama backend yang harus berpikir possibility apa saja yang terjadi apabila si API/Software tersebut diakses.

Semakin pintar seorang backend tentu saja testnya akan semakin beragam dengan kasus-kasus yang bahkan tidak masuk akal, seperti membuat test untuk menguji apabila sebuah user mengirimkan nama/email dengan jumlah 100.000 karakter. Terdengar aneh bukan? Mana ada email yang terdiri dari 100k karakter, tapi itu tetap harus diuji oleh backend untuk memastikan bahwa yang diekspektasikan tidak berhasil dan sesuai ekspektasi. Asal jangan ekspektasikan cinta kamu dibalas doi aja sih 😢, kalo ini sih auto gagal deh assertionnya HAHAHA.

TDD ini merupakan konsep yang baik apabila kalian memiliki produk yang stable dan terus berkembang seperti aplikasi Gojek, Grab, Tokopedia, AirBnB dan aplikasi lainnya yang bukanlah aplikasi projekan.

Jadi TDD menurut saya juga tidak cocok untuk requirement perusahaan software house yang mengutamakan mengejar rapid development atau pekerjaan cepat selesai dan mengedepankan black-box testing, namun bisa membuat unit test sesuai test case si QA sehingga bisa membuat laporan white-box test.

Apakah TDD Cocok Bagi Sebuah Software/API yang Sudah Terlanjur Dibangun?

Tentu saja, tugas kamu sebagai engineer adalah membuat unit test yang sesuai dengan projek yang sudah ada terlebih dahulu, lalu ketika ada fitur baru yang ingin dibangun, maka mulailah menggunakan konsep TDD, sehingga konsep TDD ini dapat terlaksana dengan baik, diskusikan terlebih dahulu dengan engineer lainnya apabila kamu bekerja didalam team jenis test standarisasi seperti apa yang harus diuji coba.

Apakah TDD Cocok Bagi Sebuah Software/API yang Dikerjakan Oleh Single Engineer?

Tentu saja, karena dasarnya TDD adalah paradigma yang baik untuk membiasakan diri dalam mengerjakan sesuatu sesuai ekspektasi terlebih dahulu, sehingga engineer bisa terbiasa membuat sebuah API/Software dengan standar ekspektasi dasar maupun advanced dari sebuah fitur yang akan dibangun.

Jadi, kapan kamu mau mulai berkonsep TDD? Tunggu apalagi? Yuk mulai buat paradigma ini di kantor/tim internalkamu sehingga kamu bisa membuat kode yang lebih bersih, lebih baik, dan lebih terorganisir!

Sekian tutorial insight ini yang semoga saja benar, apabila terjadi kesalahan baik dari tutorial maupun kata-kata yang dipilih, jangan sungkan untuk kritik melalui kolom komentar, adios, dan happy coding!

--

--

Hudya
Hudya

Written by Hudya

Which is more difficult, coding or counting? Not both of them, the difficult one is sharing your knowledge to people without asking the payment.

No responses yet