Salah satu keuntungan menggunakan Continuous Integration (CI) adalah setiap perubahan kode dapat langsung divalidasi melalui serangkaian automated tests. Namun, seiring bertambahnya unit dan feature tests, waktu eksekusi pipeline juga akan meningkat.

Pada proyek FastAPI yang sedang penulis kembangkan, GitHub Actions membutuhkan sekitar 2 menit 6 detik untuk menjalankan 335 automated tests. Waktu tersebut memang tidak terlalu lama jika hanya dijalankan sekali, tetapi dalam aktivitas pengembangan, setiap push, pull request, atau merge, akumulasi waktu pengujian mulai terasa mengganggu.

Untuk memangkas waktu, penulis mencoba melakukan profiling untuk mencari bagian mana yang benar-benar menjadi bottleneck. Hasilnya cukup menarik. Dengan beberapa optimasi sederhana namun tepat sasaran, waktu eksekusi berhasil turun menjadi sekitar 18 detik tanpa mengurangi jumlah test maupun menurunkan kualitas pengujian.

Berikut proses optimasi yang penulis lakukan.

Kondisi Awal

Lingkungan pengujian yang digunakan:

  • GitHub Actions (ubuntu-latest)
  • Python 3.12
  • FastAPI
  • SQLAlchemy 2.0
  • SQLite
  • Pytest
  • Total 335 automated tests

Benchmark awal:

  • Total waktu eksekusi: 2 menit 6 detik

Langkah Optimasi

Langkah pertama yang penulis lakukan bukan langsung mengubah kode, tetapi mencari tahu terlebih dahulu bagian mana yang paling banyak menghabiskan waktu.

1. Mengganti pip dengan uv

Bottleneck pertama ternyata berasal dari proses instalasi dependency.

Secara default, GitHub Actions akan membuat virtual environment baru dan menginstal seluruh dependency setiap kali workflow dijalankan. Walaupun cache sudah digunakan, proses instalasi menggunakan pip masih membutuhkan waktu yang cukup signifikan.

Penulis mencoba beralih menggunakan uv, package manager Python yang dikembangkan oleh Astral dan ditulis menggunakan Rust.

Workflow GitHub Actions menjadi seperti berikut.

- name: Setup uv
  uses: astral-sh/setup-uv@v5
  with:
    enable-cache: true
    cache-dependency-glob: requirements.txt

- name: Install Python
  run: uv python install 3.12

- name: Create Virtual Environment
  run: uv venv

- name: Install Dependencies
  run: uv pip install -r requirements.txt pytest-xdist

Dengan memanfaatkan cache bawaan uv, proses instalasi dependency menjadi jauh lebih cepat. Package tidak perlu diunduh ulang pada setiap workflow sehingga startup pipeline berkurang hampir satu menit.

Untuk proyek Python yang cukup sering dijalankan di CI, menurut penulis perpindahan dari pip ke uv memberikan peningkatan performa yang cukup terasa.

2. Menjalankan Test Secara Paralel

Setelah waktu instalasi berkurang, bottleneck berikutnya adalah proses eksekusi test itu sendiri.

Secara default, Pytest menjalankan seluruh test secara berurutan (single process). Padahal GitHub Actions menyediakan lebih dari satu CPU core yang bisa dimanfaatkan.

Penulis menambahkan plugin pytest-xdist kemudian menjalankan test menggunakan:

uv run pytest tests/ -v --tb=short -n auto

Parameter -n auto akan mendeteksi jumlah CPU yang tersedia kemudian membagi seluruh test ke beberapa worker.

Karena sebagian besar test bersifat independen, waktu eksekusi langsung berkurang cukup signifikan tanpa perlu mengubah implementasi test.

3. Mengurangi Cost BCrypt Saat Testing

Langkah berikutnya adalah mencari test yang paling lambat.

Pytest menyediakan fitur profiling sederhana melalui:

pytest --durations=5

Dari hasil profiling tersebut penulis menemukan bahwa proses hashing password menjadi salah satu penyumbang waktu terbesar.

Hal ini sebenarnya normal. bcrypt memang dirancang agar proses hashing berjalan lambat demi meningkatkan keamanan terhadap serangan brute force.

Di lingkungan production penulis menggunakan:

rounds = 12

Namun untuk automated testing, tingkat keamanan tersebut sebenarnya kurang diperlukan karena tujuan pengujian hanyalah memastikan fungsi berjalan dengan benar.

Solusinya adalah menggunakan konfigurasi yang berbeda saat testing.

import os
import bcrypt

IS_TESTING = os.getenv("SECRET_KEY") == "test-key"
BCRYPT_ROUNDS = 4 if IS_TESTING else 12

def get_password_hash(password: str) -> str:
    salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
    return bcrypt.hashpw(
        password.encode(),
        salt
    ).decode()

Dengan pendekatan ini, algoritma yang digunakan tetap bcrypt sehingga perilaku aplikasi tidak berubah. Yang berbeda hanyalah tingkat kompleksitas hashing ketika berada di lingkungan testing.

Waktu hashing turun dari sekitar 0,96 detik menjadi hanya beberapa milidetik.

Pada tahap ini penulis juga menghapus penggunaan passlib karena sudah mulai memunculkan beberapa deprecation warning pada Python 3.12.

4. Mengoptimalkan Pembuatan Data Testing

Bottleneck terakhir berasal dari fixture database.

Beberapa test pagination sebelumnya membuat data menggunakan:

db.add(...)

di dalam sebuah loop.

Cara tersebut todak salah, tetapi setiap pemanggilan ORM akan menambah overhead transaksi database.

Penulis menggantinya dengan bulk insert menggunakan SQLAlchemy 2.0.

from sqlalchemy import insert

db.execute(
    insert(Device),
    [
        {
            "name": f"Device {i}",
            "status": "active"
        }
        for i in range(15)
    ]
)

db.commit()

Selain itu penulis juga mengevaluasi jumlah data dummy yang dibuat.

Sebagai contoh, jika pagination hanya membutuhkan 11 data untuk menguji perpindahan halaman, maka tidak ada alasan membuat ratusan record. Fixture yang lebih kecil membuat test lebih cepat sekaligus lebih mudah dipahami.

Hasil Akhir

Setelah seluruh optimasi diterapkan, hasil benchmark menjadi sebagai berikut.

TahapOptimasiWaktuTotal Peningkatan
0Baseline2 menit 6 detik
(126 Detik)
-
1Mengaktifkan pip cache + pytest-xdist1 menit 16 detik
(76 Detik)
39,7%
2Migrasi dari pip ke uv48 detik61,9%
3Mengganti Passlib dengan bcrypt dan menurunkan rounds menjadi 4 saat testing25 detik80,2%
4Bulk insert SQLAlchemy dan mengurangi jumlah data fixture18 detik85,7%

Tidak ada satu optimasi yang secara sendirian mampu memangkas waktu hingga lebih dari satu menit. Sebaliknya, peningkatan performa diperoleh dari menghilangkan beberapa bottleneck kecil yang jika digabungkan memberikan dampak yang cukup besar.

Kesimpulan

Dari pengalaman ini penulis belajar bahwa optimasi CI tidak selalu diselesaikan dengan membeli runner yang lebih cepat atau mengurangi jumlah test. Bisa jadi masalahnya justru berada pada hal-hal kecil seperti proses instalasi dependency, konfigurasi library, strategi pembuatan fixture_, atau pemanfaatan CPU yang belum optimal.

Jika pipeline GitHub Actions di proyek mulai terasa lambat, penulis menyarankan untuk tidak langsung melakukan optimasi secara acak. Mulailah dengan melakukan profiling, identifikasi bottleneck terbesar, kemudian optimalkan satu per satu. Dengan pendekatan tersebut, setiap perubahan yang dilakukan memiliki alasan yang jelas dan hasilnya pun dapat diukur.