Kegagalan pengujian otomatis di pipeline CI/CD sering kali bukan disebabkan oleh regresi kode aplikasi, melainkan oleh faktor eksternal non-deterministik (flaky test). Salah satu sumber utama ketidakstabilan ini adalah kebocoran panggilan jaringan (outbound HTTP call) ke API pihak ketiga seperti gateway pembayaran, provider email, atau webhook eksternal.

Ketika tes unit atau integrasi mengeksekusi koneksi internet riil, suite pengujian rentan terhadap latensi jaringan, kegagalan DNS, batas laju request (rate limit), dan downtime vendor. Solusi arsitektural untuk masalah ini adalah pemblokiran total soket jaringan di level runtime pengujian, dengan tetap membuka akses untuk loopback database dan service lokal.

Penyebab Flaky Test Akibat Outbound Call

Panggilan jaringan riil di dalam test suite memicu tiga masalah utama:

  • Non-deterministik: Respons jaringan pihak ketiga bergantung pada ketersediaan eksternal, bukan status basis kode lokal.
  • Degradasi Performa: Request HTTP over internet memperkenalkan latensi I/O dalam hitungan ratusan milidetik per request, memperlambat siklus CI.
  • Efek Samping Eksternal: Kebocoran data pengujian ke lingkungan sandbox/production penyedia API pihak ketiga yang dapat menghabiskan kuota pengujian.

Suite pengujian yang benar harus sepenuhnya terisolasi (hermetic tests). Komponen eksternal harus disimulasikan menggunakan stub atau mock, sementara koneksi jaringan riil di luar mesin host harus diputus paksa.

Metode 1: Blokir Socket Menggunakan pytest-socket

Jika proyek Django menggunakan pytest dan pytest-django, pustaka pytest-socket menyediakan mekanisme paling cepat dan minim konfigurasi untuk membatasi akses soket Python pada level standard library (socket.socket.connect).

Instalasi dan Konfigurasi

Pasang package ke environment virtual:

pip install pytest-socket

Konfigurasikan pembatasan soket pada file pytest.ini atau setup.cfg:

[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
addopts = 
    --disable-socket
    --allow-hosts=127.0.0.1,localhost,::1

Argumen --disable-socket akan melempar eksepsi SocketBlockedError setiap kali kode memanggil socket.connect(). Argumen --allow-hosts memberikan pengecualian eksplisit (whitelist) ke alamat IP loopback.

Mengizinkan Akses Socket Tertentu (Edge Case)

Jika pengujian integrasi spesifik (misalnya: live server testing atau end-to-end testing dengan Selenium) membutuhkan akses jaringan, gunakan marker pytest secara selektif:

import pytest

@pytest.mark.enable_socket
def test_third_party_integration_legacy():
    # Tes ini diizinkan membuka soket jaringan riil
    pass

Metode 2: Custom Django Test Runner (Native Django)

Untuk proyek yang tidak menggunakan pytest dan bergantung langsung pada manage.py test, pemblokiran soket dapat diimplementasikan via custom test runner yang mem-patch method connect pada modul standar socket.

Buat class test runner kustom pada common/runner.py:

import socket
from django.test.runner import DiscoverRunner

class SocketBlockedError(RuntimeError):
    pass

def apply_socket_firewall(allowed_hosts):
    original_connect = socket.socket.connect

    def guarded_connect(self, address):
        host = address[0]
        # Resolusi tuple socket: (host, port)
        if host not in allowed_hosts:
            raise SocketBlockedError(
                f"Outbound network call to {host} is forbidden in test suite."
            )
        return original_connect(self, address)

    socket.socket.connect = guarded_connect

class IsolatedDiscoverRunner(DiscoverRunner):
    def setup_test_environment(self, **kwargs):
        super().setup_test_environment(**kwargs)
        apply_socket_firewall(allowed_hosts={"127.0.0.1", "localhost", "::1"})

Daftarkan runner tersebut di settings/test.py:

TEST_RUNNER = "common.runner.IsolatedDiscoverRunner"

Konfigurasi Whitelist: PostgreSQL dan Redis Lokal

Saat soket dinonaktifkan, Django test runner tetap membutuhkan soket TCP/IP untuk berkomunikasi dengan database pengujian (PostgreSQL/MySQL) dan memory store (Redis/Memcached).

Jika database atau cache service dikonfigurasi menggunakan Unix Domain Sockets, pemblokiran TCP socket tidak akan berpengaruh. Namun, jika service menggunakan TCP host:

  • Pastikan konfigurasi DATABASES['default']['HOST'] disetel ke 127.0.0.1 atau localhost.
  • Hindari penggunaan nama domain lokal yang memerlukan resolusi DNS upstream di luar loopback jika resolusi tersebut memicu network hit.
  • Bila menggunakan container Docker terpisah di CI (misal: service container di GitHub Actions bernama postgres atau redis), tambahkan hostname container tersebut ke parameter whitelist.
# pytest.ini jika database berada di service container Docker
addopts = --disable-socket --allow-hosts=127.0.0.1,localhost,postgres,redis

Mocking Outbound HTTP Call

Ketika soket eksternal diblokir, setiap service atau client HTTP (seperti requests, httpx, atau urllib) yang dipanggil oleh kode aplikasi harus di-mock. Pendekatan standar industri menggunakan library responses untuk client requests atau unittest.mock bawaan Python.

Contoh Implementasi dengan responses

import responses
import requests
from django.test import SimpleTestCase

def fetch_exchange_rate(currency: str) -> float:
    response = requests.get(f"https://api.exchangerate.host/latest?base={currency}", timeout=5)
    response.raise_for_status()
    return response.json()["rates"]["IDR"]

class ExchangeRateServiceTests(SimpleTestCase):
    @responses.activate
    def test_fetch_exchange_rate_success(self):
        # Mencegat HTTP request sebelum menyentuh standard socket layer
        responses.add(
            method=responses.GET,
            url="https://api.exchangerate.host/latest?base=USD",
            json={"rates": {"IDR": 15500.0}},
            status=200,
        )

        rate = fetch_exchange_rate("USD")
        self.assertEqual(rate, 15500.0)

Jika responses.add() tidak didefinisikan, pustaka responses akan mengabaikan URL atau melempar error koneksi. Jika responses tidak diaktifkan sama sekali, layer pytest-socket atau custom runner akan memutus eksekusi dengan SocketBlockedError.

Verifikasi pada Pipeline CI/CD

Pastikan isolasi jaringan teruji secara konsisten di server CI (misal: GitHub Actions). Sertakan satu unit test validasi yang secara eksplisit memverifikasi bahwa soket diblokir jika mencoba menghubungi domain luar.

import pytest
import urllib.request
from pytest_socket import SocketBlockedError

def test_outbound_network_is_strictly_blocked():
    with pytest.raises(SocketBlockedError):
        urllib.request.urlopen("https://1.1.1.1", timeout=1)

Contoh implementasi langkah pengujian pada job GitHub Actions:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        ports:
          - 5432:5432
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: user
          POSTGRES_PASSWORD: password
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install pytest-socket
      - name: Run Test Suite
        env:
          DATABASE_URL: postgres://user:[email protected]:5432/test_db
        run: |
          pytest --disable-socket --allow-hosts=127.0.0.1,localhost

Pemisahan total I/O jaringan luar memastikan test suite Django berjalan deterministik, mengeliminasi false alarm pada CI, dan secara signifikan mempercepat waktu eksekusi run test.