Tự host API mocking và testing trên VPS
Chạy WireMock và Hurl trên VPS: lưu stub trong git, chạy suite trong CI, giữ report sau khi rebuild và tránh đưa token production vào fixture.
Hai tác vụ dùng chung một repository
Tự host API mocking và testing là hai tác vụ khác nhau. Gộp chúng thành một sẽ làm lãng phí cả tuần. Mock server đóng vai một dependency mà bạn không thể gọi từ CI: payment provider, API của đối tác, upstream bị giới hạn rate, hoặc service mà team khác chưa release. API test runner gọi các endpoint của chính bạn theo một thứ tự cố định và kiểm tra response, rồi chuyển các giá trị từ response này vào request tiếp theo.
Hai tác vụ này không chồng lấn. Mock server không bao giờ báo pass hoặc fail. Test runner không quan tâm payment provider trả về gì khi thẻ bị từ chối. Hầu hết team đã thuê sẵn một server đều chạy một instance cho mỗi loại, khởi động bằng cùng một file Docker Compose và review trong cùng một pull request.
Vì sao nên tự host việc mock và test API?
Dữ liệu fixture có cấu trúc giống dữ liệu production. Request body trong một bài test API có thể là bản ghi khách hàng thật chỉ được đổi tên, hoặc vẫn giữ nguyên tên vì chưa ai kiểm tra. Stub được ghi lại còn nguy hiểm hơn: proxy recording lưu bất kỳ dữ liệu nào upstream thực sự trả về. Vì vậy, một thư mục stub được tạo bằng recording có thể chứa token đang hoạt động và địa chỉ email khách hàng cho đến khi có người đọc từng file. Với một hosted service, dữ liệu đó sẽ trở thành sự cố của bên khác và dẫn đến việc bạn phải disclosure.
Lý do thứ hai là khả năng truy cập. Một service bind vào địa chỉ private sẽ không thể được hosted runner truy cập, nên test không thể chạy. Mỗi cách workaround đều có chi phí. Public API ra Internet để test sẽ làm mất lý do ban đầu khiến API ở private. Tunnel hoặc một bản staging public là một hệ thống khác cần bảo trì, và bản staging sẽ dần lệch khỏi production giữa các lần release. Runner nằm trên cùng private network có thể gọi trực tiếp đến service và không cần các cách đó. Đây là lý do thực tế để dùng GitHub Actions runner tự host.
Nên chạy mock server self-hosted nào?
Mỗi lựa chọn đều chạy dưới dạng container trên một máy chủ do bạn sở hữu. Điều quan trọng là mỗi công cụ xem dữ liệu nào là nguồn dữ liệu chuẩn, vì điều đó quyết định việc dựng lại container không tốn gì hay khiến bạn mất cả buổi chiều.
- WireMock lưu từng stub dưới dạng file JSON trong thư mục
mappings/, còn response body lớn nằm trong__files/. Image làwiremock/wiremock, thư mục root bên trong container là/home/wiremock, và công cụ này cũng chạy được như một recording proxy. Lưu file trên disk nghĩa là mock có thể nằm trong git như mọi đoạn code khác. - Mockoon CLI lưu toàn bộ mock API trong một file dữ liệu JSON. Cài bằng
npm install -g @mockoon/clirồi khởi động bằngmockoon-cli start --data ./data-file.json, hoặc chạy imagemockoon/clivới file đó được bind mount. Desktop app chỉnh sửa cùng file, nên bạn có thể thiết kế trong UI rồi commit kết quả mà vẫn tương thích. - MockServer chạy từ image
mockserver/mockservervà lắng nghe trên port 1080. Các expectation được gửi qua REST API riêng của nó. Cách này tiện khi gọi từ test code nhưng rủi ro khi deploy: expectation được tạo bằng một HTTP call sẽ biến mất khi container restart. Dùng file khởi tạo JSON cho các stub cần tồn tại lâu dài. - Prism dựng mock từ tài liệu OpenAPI thay vì từ các file stub riêng. Cài bằng
npm install -g @stoplight/prism-clirồi chạy bằngprism mock openapi.yaml. Khi chạy trong container, thêm-h 0.0.0.0vì Prism mặc định bind vào localhost; nếu không thêm, nó sẽ không thể nhận kết nối từ bên ngoài container. - Microcks là lựa chọn lớn: một web UI import tài liệu OpenAPI và Postman collection, sau đó cung cấp chúng dưới dạng mock và chạy contract test. Bản cài đặt đầy đủ cần MongoDB và Keycloak, cùng với Kafka cho các tính năng async. Image all in one
microcks-ubertích hợp MongoDB trong memory. Project ghi rõ image này phù hợp cho việc sử dụng tạm thời, vì vậy hãy xem mọi thứ được tạo trong UI là dữ liệu disposable và lưu các source artifact trong git.
Nên chạy API test runner nào trên hệ thống tự host?
Công việc ở đây là một chuỗi thao tác: xác thực, tạo order, đọc lại order đó, rồi kiểm tra state đã thay đổi. Bạn cần lấy một value từ response này và dùng nó trong request tiếp theo. Một tool không thể duy trì state giữa các lần gọi chỉ là health check, không phải API test.
- Hurl chạy các file văn bản thuần chứa HTTP request từ một binary duy nhất. Section
[Captures]lấy value từ response, section[Asserts]kiểm tra các value đó, còn--testbiến nó thành test runner có summary và exit code. Version 8.0.1 là version hiện tại tính đến tháng 8 năm 2026. - Bruno CLI chạy một thư mục chứa các file
.bru. Cài đặt bằngnpm install -g @usebruno/cli, sau đó chạy bằngbru run folder --env Local --reporter-junit results.xml. Collection format vốn được thiết kế dưới dạng các file văn bản trong một directory, nên diff dễ đọc khi review. - Newman chạy Postman collection bên ngoài Postman:
npm install -g newman, sau đónewman run collection.json -r cli,junit --reporter-junit-export results.xml. Điểm cần lưu ý là format. Collection là một JSON blob được export, nên việc chỉnh sửa diễn ra trong Postman và file trong git chỉ là một bản sao có thể trở nên lỗi thời. - Schemathesis là một dạng check khác. Nó đọc OpenAPI schema và tạo các case nhằm cố tạo ra những response mà schema của bạn cho là không thể xảy ra:
uvx schemathesis run https://your.api/openapi.json. Tool này tìm được crash và contract violation, nhưng không biết gì về business rule của bạn. Vì vậy, nó nên chạy cùng scripted suite thay vì thay thế suite đó. - Hoppscotch self-hosted là lựa chọn web UI và yêu cầu một Postgres instance. Hãy hiểu rõ tradeoff này trước khi cài đặt: collection được lưu trong database, không phải trong repository.
Có một tool nên tránh. Step CI vẫn xuất hiện trong các bài tổng hợp tool và YAML workflow format của nó khá dễ đọc, nhưng repository này lần cuối nhận commit vào tháng 8 năm 2024. Một chương trình nằm giữa CI và API của bạn không phải vị trí phù hợp cho code không còn được duy trì.
Đặt mock server phía sau firewall
Thiết lập dưới đây chạy WireMock để giả lập một payment provider. Nếu format của file compose còn mới với bạn, Docker Compose trên VPS trình bày các lệnh quản lý vòng đời mà phần này sử dụng.
services:
mock-payments:
image: wiremock/wiremock:3.13.2
command: ["--verbose"]
volumes:
- ./mocks/payments:/home/wiremock
ports:
- "127.0.0.1:8080:8080"
restart: unless-stoppedPhần tiền tố 127.0.0.1: trên port là điểm quan trọng. Dùng 8080:8080 trần sẽ publish mock trên mọi interface, bao gồm cả public IP của bạn. Mock vẫn có thể được truy cập dù ufw đang deny port đó, vì Docker tự ghi rule vào chain DOCKER của iptables và các rule này được đánh giá trước rule INPUT của ufw. Thay vào đó, hãy bind vào địa chỉ loopback hoặc địa chỉ của một private interface. Khi đó kernel không bao giờ nhận connection từ bên ngoài.
Service đang được test sẽ trỏ đến mock. Khi service chạy trong cùng compose project, base URL của mock là http://mock-payments:8080, vì compose tự resolve tên service trên network riêng của project. Khi service chạy trên host, base URL là http://127.0.0.1:8080. Hãy đặt giá trị này qua environment variable, không hard-code trong code, nếu không test URL có thể được release lên production.
Các stub nằm trong ./mocks/payments/mappings/, mỗi file chứa một JSON.
{
"request": {
"method": "POST",
"urlPath": "/v1/charges",
"bodyPatterns": [{ "matchesJsonPath": "$.amount" }]
},
"response": {
"status": 201,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "id": "ch_test_001", "status": "succeeded", "amount": 4200 }
}
}Khởi động service, sau đó kiểm tra những gì thực sự đã được load.
docker compose up -d --wait mock-payments
curl -fsS http://127.0.0.1:8080/__admin/mappings--wait chờ cho đến khi container báo healthy. Cơ chế này hoạt động vì image WireMock có sẵn một HEALTHCHECK cho endpoint /__admin/health. Lệnh mappings liệt kê mọi stub mà server đã đọc. Nếu stub bạn viết không có trong danh sách, stub đó chưa được load. Hãy kiểm tra file nằm dưới mappings/ thay vì nằm ở thư mục root được mount, đồng thời kiểm tra JSON có parse được hay không.
Khi có request đến nhưng không khớp stub nào, WireMock trả về 404 với body bắt đầu bằng Request was not matched, sau đó là diff so với stub gần khớp nhất mà server đang có. Hãy đọc diff trước khi thay đổi bất kỳ thứ gì, vì diff chỉ rõ field nào khác nhau. Thông thường đó là một path có /v1/charge trong khi stub khai báo /v1/charges.
Viết test theo chuỗi, trong đó trạng thái được giữ lại giữa các lần gọi
File Hurl là file văn bản thuần túy. Cài đặt gói deb từ trang releases của project.
VERSION=8.0.1
curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb
sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.debMột suite gọi API của chính bạn và kiểm thử API đó với mock nằm trong tests/checkout.hurl.
POST {{base_url}}/orders
Content-Type: application/json
{
"sku": "ssd-1tb",
"amount": 4200
}
HTTP 201
[Captures]
order_id: jsonpath "$['id']"
GET {{base_url}}/orders/{{order_id}}
HTTP 200
[Asserts]
jsonpath "$.status" == "paid"
jsonpath "$.charge_id" == "ch_test_001"Block [Captures] biến đây thành một API test thay vì 2 request không liên quan. order_id được đọc từ response đầu tiên rồi nội suy vào URL của request thứ 2. Assertion trên charge_id là mục tiêu chính của toàn bộ bài test: nó xác nhận service của bạn đã gọi payment provider và lưu lại dữ liệu trả về. Giá trị dùng để so sánh là giá trị bạn đã ghi vào WireMock stub. Một file hiện bao phủ cả 2 nửa của flow.
hurl --test --variable base_url=http://127.0.0.1:3000 \
--report-junit reports/junit.xml \
--report-json reports/json \
tests/Lần chạy thành công in 1 dòng cho mỗi file và một bản tóm tắt.
tests/checkout.hurl: Success (2 request(s) in 61 ms)
Executed files: 1
Executed requests: 2 (30.1/s)
Succeeded files: 1 (100.0%)
Failed files: 0 (0.0%)
Duration: 64 msKhi có lỗi, công cụ in error: Assert failure cùng với tên file và số dòng, sau đó in giá trị thực tế cạnh giá trị mong đợi. hurl thoát với mã khác 0 để CI dừng lại. Nếu status đọc được pending trong khi bạn mong đợi paid, service của bạn đã không xử lý response từ mock. Việc tiếp theo cần đọc là request journal của WireMock tại /__admin/requests. Journal này cho biết request có đến được mock hay không.
Kích hoạt bộ kiểm thử từ CI runner của bạn
Với runner đã đăng ký trên cùng máy chủ, workflow rất ngắn. Runner chỉ là một process chạy trên host, vì vậy docker và hurl phải được cài trên host đó. Runner không kế thừa bất kỳ thành phần nào từ hosted image.
name: api-tests
on: [push]
jobs:
hurl:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Start the mock
run: docker compose up -d --wait mock-payments
- name: Run the suite
run: hurl --test --variable base_url=http://127.0.0.1:3000 --report-junit reports/junit.xml tests/
- name: Archive the reports
if: always()
run: install -d /srv/api-tests/reports/$GITHUB_SHA && cp -r reports/. /srv/api-tests/reports/$GITHUB_SHA/
- name: Stop the mock
if: always()
run: docker compose downif: always() trong bước lưu trữ archive rất quan trọng. Nếu thiếu nó, một lần chạy test thất bại sẽ bỏ qua bước sao chép, khiến bạn mất đúng report cần đọc. Bản sao cũng phải được đặt bên ngoài workspace, vì runner sẽ dọn workspace trước job tiếp theo và các report cũng sẽ bị xóa theo.
Lưu kết quả, không chỉ lần chạy gần nhất
Một file JUnit XML cho mỗi commit trả lời được một câu hỏi: kiểm thử có pass không. Nó không cho biết thời điểm một endpoint bắt đầu chậm hơn, vì không có gì đọc các file đó sau khi bạn ngừng mở chúng. Để theo dõi xu hướng, hãy thêm một row cho mỗi lần chạy vào một database nhỏ trên cùng máy. Chỉ cần một table chứa commit SHA, tên file, số lần pass, số lần fail và thời lượng là đủ. SQLite trong production trên một VPS là nơi hợp lý để lưu dữ liệu: chỉ một file, không cần server process, và toàn bộ lịch sử sẽ nằm trong bản backup bạn vẫn tạo. Hãy parse output --report-json của Hurl thay vì JUnit XML, vì đây là format machine-readable trong hai format đó.
Những gì phải tồn tại sau khi rebuild container
Định nghĩa mock và các test suite là source code. Chúng thuộc về repository cùng với service mà chúng mô tả, và phải được thay đổi trong cùng pull request với endpoint tương ứng. Một stub được chỉnh sửa trong web UI hoặc một expectation được gửi đến MockServer qua REST API lúc runtime chỉ tồn tại trong memory của container đó hoặc trong database của công cụ đó. Chạy docker compose down là dữ liệu biến mất, và không ai nhận ra cho đến khi một test bắt đầu pass vì lý do sai. Nếu các repository cũng chạy trên phần cứng của bạn, một git server tự host sẽ giữ fixture và service trong cùng một trust boundary.
Tiếp theo là các quy tắc thực tế. Hãy pin image tag, vì latest có thể thay đổi cách mock khớp request mà không cần thay đổi repository, và rất khó liên hệ lỗi đó với nguyên nhân. Hãy mount các thư mục stub ở chế độ read-only khi tool không cần ghi vào đó. Không bao giờ đặt stub của mock trong named Docker volume, vì khi đó volume trở thành source of truth còn bản copy trong git âm thầm trở nên sai.
Còn một điểm nữa, và nó thường khiến mọi người mắc lỗi. Nếu bạn tạo stub bằng cách record network traffic thực qua proxy, hãy đọc toàn bộ file được tạo ra trước khi commit. Bản recording chứa chính xác những gì upstream gửi về, bao gồm bearer token và địa chỉ email của khách hàng. Commit dữ liệu đó sẽ đưa nó vĩnh viễn vào repository, vì git giữ lại nội dung đã bị xóa trong history.
FAQ
API mock server và API test runner khác nhau như thế nào?
Một mock server trả lời các request. Nó thay thế cho một dependency mà bạn không thể gọi từ CI và không bao giờ báo pass hay fail. API test runner gửi request đến service của bạn, kiểm tra response bằng các assertion, truyền giá trị từ lần gọi này sang lần gọi tiếp theo và thoát với mã khác 0 khi một assertion fail. Hai công cụ này giải quyết các vấn đề khác nhau. Một setup điển hình chạy cả hai cùng lúc: runner gọi service của bạn, còn service của bạn gọi mock.
Tôi có thể test API nội bộ từ hosted CI runner không?
Không, nếu không expose API đó. Hosted runner nằm ngoài network của bạn nên không thể truy cập service đang bind vào một địa chỉ private. Các lựa chọn của bạn là public API, chạy tunnel hoặc duy trì một bản staging public. Mỗi lựa chọn đều thêm một system có thể fail hoặc làm lộ dữ liệu. Runner nằm trên cùng private network có thể gọi trực tiếp đến service. Đây là lý do thực tế chính khiến các team tự host công việc này.
Nên lưu mock stub và API test suite ở đâu?
Trong git, ngay cạnh service mà chúng mô tả. Các tool lưu definition dưới dạng file, như thư mục mappings/ của WireMock, data file của Mockoon, file Hurl và thư mục .bru của Bruno, cho phép code review và rebuild container mà không phát sinh chi phí đáng kể. Các tool lưu definition trong database hoặc web UI cần có kế hoạch backup và bước export. Export là phần mọi người thường quên cho đến khi container đã biến mất.
Tại sao mock của tôi trả về 404 khi stub có vẻ đúng?
WireMock chỉ serve một stub khi request khớp chính xác. Request không khớp sẽ nhận 404 với body bắt đầu bằng Request was not matched, sau đó là diff so với stub gần khớp nhất. Diff này chỉ rõ field bị khác. Các nguyên nhân thường gặp là path có trailing slash, thiếu header Content-Type mà stub yêu cầu, dùng urlPath trong khi stub cần urlPathPattern cho một segment biến đổi, hoặc body matcher không khớp với payload. Trước tiên, hãy kiểm tra /__admin/requests để xác nhận request thực sự đã đến mock.
Nếu đã có staging environment thì tôi còn cần mock không?
Có, vì 2 lý do. Bản staging của upstream mà bạn không kiểm soát vẫn có thể ngừng hoạt động hoặc rate limit bạn. Khi đó suite fail vì những lý do không liên quan đến code của bạn. Bản staging cũng không thể tạo ra những response mà bạn cần test nhất, chẳng hạn như giao dịch thẻ bị từ chối hoặc gateway timeout. Mock trả về các response đó theo yêu cầu với tốc độ của local network. Nhờ vậy, một suite mất vài phút khi chạy với sandbox có thể chỉ mất vài giây. Hãy giữ staging cho lần kiểm tra cuối trước khi release và dùng mock trong CI.