SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Tự host API mocking và testing trên VPS

Chạy WireMock và Hurl trên VPS: lưu stub trong git, chạy test suite trong CI, giữ report sau rebuild và tránh để token thật lọt vào fixture.

Hai công việc dùng chung một repository

Tự host API mocking và API testing là hai công việc khác nhau. Gộp chúng làm một sẽ lãng phí cả tuần. Mock server đại diện cho một dependency mà CI không thể gọi đến: payment provider, partner API, 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 dùng các giá trị từ một response cho request tiếp theo.

Hai công việc 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 các team đã thuê một máy chủ đều chạy một instance của mỗi loại, khởi động bằng cùng một Docker Compose file và review trong cùng một pull request.

Tại sao nên tự host việc mock và kiểm thử API?

Dữ liệu fixture của bạn 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ì không ai kiểm tra. Recorded stub còn nguy hiểm hơn: việc ghi proxy lưu lại đúng dữ liệu upstream đã trả về, nên thư mục stub được tạo bằng cách ghi 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. Trên một dịch vụ hosted, dữ liệu đó sẽ trở thành sự cố và sự cố lộ dữ liệu của bạn do bên khác xử lý.

Lý do thứ hai là khả năng truy cập. Một service bind vào địa chỉ private thì không thể được hosted runner truy cập, nên test không thể chạy. Mọi cách khắc phục đều có chi phí. Public API ra Internet để kiểm thử sẽ loại bỏ lý do ban đầu khiến API ở private. Một tunnel hoặc bản staging public là một hệ thống khác cần bảo trì, còn bản staging sẽ lệch so với production giữa các lần release. Runner nằm trên cùng private network có thể gọi service trực tiếp và không cần các cách đó. Đây là lý do thực tế để dùng runner GitHub Actions tự host.

Nên chạy mock server tự host nào?

Mỗi lựa chọn này đề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ụ coi thứ gì là nguồn dữ liệu chuẩn, vì điều đó quyết định việc dựng lại container sẽ không tốn gì hay khiến bạn mất cả buổi chiều.

  • WireMock lưu mỗi 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 có thể chạy như một proxy ghi lưu lượng. File nằm trên disk nghĩa là mock được lưu trong git như mọi 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/cli và khởi động bằng mockoon-cli start --data ./data-file.json, hoặc chạy image mockoon/cli với file đó được bind mount. Desktop app chỉnh sửa cùng file, nên bạn có thể thiết kế bằng UI rồi commit kết quả mà không gặp vấn đề tương thích.
  • MockServer chạy từ image mockserver/mockserver và 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 có rủi ro khi triển khai: expectation được tạo bằng HTTP call sẽ biến mất khi container khởi động lại. Dùng file khởi tạo JSON của nó cho các stub cần tồn tại lâu dài.
  • Prism tạo 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-cli, sau đó chạy prism mock openapi.yaml. Khi chạy trong container, thêm -h 0.0.0.0, vì Prism mặc định bind vào localhost và nếu không thêm tùy chọn này thì bên ngoài container không thể truy cập được.
  • Microcks là lựa chọn lớn hơn: một web UI có thể 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 Kafka cho các tính năng bất đồng bộ. Image all-in-one microcks-uber tích hợp MongoDB in-memory. Project ghi rõ image này phù hợp cho mục đích tạm thời, vì vậy hãy coi mọi thứ được tạo trong UI là dữ liệu có thể xóa và lưu các artifact nguồn trong git.

Nên chạy công cụ self-hosted nào để kiểm thử API?

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 giá trị từ response này và dùng nó trong request tiếp theo. Công cụ không thể duy trì state giữa các lần gọi chỉ là công cụ health check, không phải công cụ kiểm thử API.

  • 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 giá trị từ response, section [Asserts] kiểm tra các giá trị đó, còn --test biến nó thành test runner có summary và exit code. Version 8.0.1 là bản 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ằng npm install -g @usebruno/cli, sau đó chạy bru run folder --env Local --reporter-junit results.xml. Collection được thiết kế dưới dạng các file văn bản trong một thư mục, 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 bất tiện nằm ở 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, dễ bị lỗi thời.
  • Schemathesis là một dạng kiểm tra khác. Nó đọc OpenAPI schema và tạo các case nhằm tạo ra những response mà schema cho là không thể xảy ra: uvx schemathesis run https://your.api/openapi.json. Công cụ này tìm được crash và lỗi vi phạm contract, nhưng không biết các business rule của bạn. Vì vậy, nó nên chạy bên cạnh test suite viết bằng script thay vì thay thế test suite đó.
  • Hoppscotch self-hosted là lựa chọn có web UI và yêu cầu một Postgres instance. Hãy hiểu rõ đánh đổi này trước khi cài đặt: collection được lưu trong database, không nằm trong repository.

Có một công cụ nên tránh. Step CI vẫn xuất hiện trong các bài tổng hợp công cụ và format workflow YAML 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 nên sử dụng code đã không còn được maintain.

Đặt mock server phía sau firewall

Thiết lập dưới đây chạy WireMock để giả lập payment provider. Nếu bạn chưa quen với định dạng compose file, Docker Compose trên VPS trình bày các lệnh quản lý vòng đời được dùng trong phần này.

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-stopped

Tiền tố 127.0.0.1: trên port là phần quan trọng. 8080:8080 trống sẽ publish mock trên mọi interface, bao gồm cả public IP của bạn. Mock vẫn có thể truy cập được dù ufw đang deny port đó, vì Docker tự ghi rule vào chain DOCKER của iptables và các rule này được evaluate trước rule INPUT của ufw. Thay vào đó, hãy bind vào địa chỉ loopback hoặc địa chỉ của private interface. Khi đó, kernel sẽ không accept connection từ bên ngoài.

Service được kiểm thử sau đó 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 service name trên network riêng của project. Khi service chạy trên host, URL là http://127.0.0.1:8080. Hãy đặt giá trị này qua environment variable, không ghi trực tiếp trong code. Nếu không, test URL có thể được ship lên production.

Các stub đặt 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 mock, 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 sẽ block cho đến khi container báo healthy. Cách này hoạt động vì WireMock image ship 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 trong root được mount, đồng thời kiểm tra JSON có parse được hay không.

Khi có request đến nhưng không stub nào khớp, WireMock trả về 404 với body bắt đầu bằng Request was not matched, tiếp theo là diff so với stub gần 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 một chuỗi, trong đó trạng thái được giữ lại giữa các lần gọi

File Hurl là văn bản thuần túy. Cài gói deb từ phần releases của dự án.

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.deb

Một bộ test gọi API của chính bạn thông qua mock nằm tại 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ì hai 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ứ hai. Assertion trên charge_id là mục tiêu chính của toàn bộ bài test: nó chứng minh service của bạn đã gọi payment provider và lưu lại dữ liệu nhận được, còn giá trị dùng để so sánh là giá trị bạn đã ghi vào WireMock stub. Một file giờ đây bao phủ cả hai 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 một 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 ms

Khi thất bại, công cụ in error: Assert failure cùng với 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 của mock. Việc tiếp theo cần kiểm tra 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ộ test từ CI runner của bạn

Với runner được đăng ký trên cùng máy chủ, workflow này khá ngắn. Runner chỉ là một process chạy trên host, vì vậy dockerhurl phải được cài trên host đó. Runner không kế thừa gì 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 down

if: always() trong bước lưu trữ rất quan trọng. Nếu thiếu nó, một lần chạy test bị lỗi sẽ bỏ qua bước copy, khiến bạn mất đúng report cần đọc. File copy 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 bị xóa theo.

Lưu kết quả, không chỉ lần chạy cuối

Một file JUnit XML cho mỗi commit chỉ trả lời được một câu hỏi: kiểm thử có pass hay không. Nó không cho biết từ khi nào một endpoint bắt đầu chậm hơn, vì không có thành phần nào đọ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ủ. Chỉ cần một table chứa commit SHA, tên file, số lượng pass, số lượng fail và thời lượng là đủ. SQLite trong môi trường production trên 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ẽ được sao lưu cùng với bản backup bạn đã có. Hãy parse output --report-json của Hurl thay vì JUnit XML, vì đây là định dạng machine-readable trong hai định dạng.

Những gì phải tồn tại sau khi rebuild container

Các định nghĩa mock và test suite là source code. Chúng phải nằm trong repository cùng với service mà chúng mô tả, và được thay đổi trong cùng pull request với thay đổi endpoint. Một stub được chỉnh sửa trong web UI hoặc một expectation được đẩy vào 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à mọi thứ 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 repository của bạn cũng chạy trên phần cứng do bạn tự quản lý, 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à repository của bạn không thay đổi, 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 công cụ 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 nguồn dữ liệu chuẩn còn bản copy trong git âm thầm trở nên sai.

Còn một điểm nữa rất dễ gây lỗi. Nếu bạn tạo stub bằng cách ghi lại network traffic thực qua proxy, hãy đọc mọi file được tạo trước khi commit. Bản ghi chứa chính xác dữ liệu upstream gửi về, bao gồm bearer token và địa chỉ email khách hàng. Commit dữ liệu đó sẽ đưa nó vĩnh viễn vào repository, vì git giữ nội dung đã xóa trong history.

FAQ

Sự khác nhau giữa mock server của API và trình chạy kiểm thử API là gì?

Mock server trả lời các request. Nó thay cho một dependency mà bạn không thể gọi từ CI và không bao giờ báo pass hoặc fail. Trình chạy kiểm thử API gửi request đến service của bạn, kiểm tra response bằng assertion, truyền các 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 thành phần 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: trình chạy gọi service của bạn, còn service của bạn gọi mock.

Tôi có thể kiểm thử API nội bộ từ hosted CI runner không?

Không, nếu không expose API đó. Hosted runner nằm bên ngoài mạng của bạn nên không thể truy cập service đang bind vào địa chỉ private. Các lựa chọn của bạn là publish API, chạy tunnel hoặc duy trì một bản staging public. Mỗi lựa chọn đều bổ sung một hệ thống 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 phần việc này.

Nên lưu mock stub và bộ test API ở đâu?

Lưu trong git, cạnh service mà chúng mô tả. Các tool lưu definition dưới dạng file, chẳng hạn 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.

Vì sao mock của tôi trả về 404 khi stub có vẻ đúng?

WireMock chỉ phục vụ 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 nhất. Diff này chỉ rõ field bị khác. Các nguyên nhân phổ biến gồm path có trailing slash, header Content-Type mà stub yêu cầu nhưng client không gửi, dùng urlPath trong khi stub cần urlPathPattern cho một segment biến đổi, và 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ó môi trường staging thì tôi vẫ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 đó test suite fail vì những nguyên nhân không liên quan đến code của bạn. Bản staging cũng không thể tạo ra các response mà bạn cần kiểm thử nhất, chẳng hạn 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, test suite chạy mất vài phút trên sandbox có thể chỉ mất vài giây với mock. Hãy giữ staging cho lần kiểm tra cuối trước khi release và dùng mock trong CI.