Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

k6 — Setup & Teardown

Mở đầu — vì sao bài này quan trọng

Nếu bạn từng viết một kịch bản k6 và tự hỏi: "Làm sao để mình đăng nhập một lần lấy token, rồi cho cả nghìn user ảo (VU) dùng chung token đó?", hoặc "Làm sao để sau khi test xong, hệ thống tự dọn dẹp những đơn hàng rác mà test đã tạo ra?", thì bài học này chính là câu trả lời.

k6 không phải là một script chạy tuần tự từ trên xuống dưới như một file Python thông thường. Nó có một vòng đời (lifecycle) được chia thành các giai đoạn rõ ràng, và mỗi giai đoạn chạy với tần suất khác nhau — có giai đoạn chạy đúng một lần cho toàn bộ bài test, có giai đoạn chạy hàng triệu lần. Nếu bạn không hiểu cơ chế này, bạn sẽ mắc những lỗi rất tốn kém: gọi API đăng nhập một triệu lần thay vì một lần, làm nghẽn chính cái server bạn muốn đo; hoặc để lại một đống dữ liệu rác trong database production sau khi test.

setup()teardown() là hai hàm lifecycle đặc biệt của k6, đóng vai trò như "mở màn" và "hạ màn" cho một buổi diễn tải. Hiểu chúng thật kỹ sẽ giúp bài test của bạn vừa chính xác, vừa sạch sẽ, vừa tiết kiệm tài nguyên. Đây là kiến thức bản lề tách biệt giữa người "biết chạy k6" và người "viết được kịch bản k6 chuyên nghiệp cho production".

Khái niệm cốt lõi

k6 chia một bài test thành bốn giai đoạn vòng đời, chạy theo đúng thứ tự sau:

// 1. Init code — chạy 1 lần cho MỖI VU (và 1 lần khi khởi tạo)
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = { vus: 10, duration: '30s' };

// 2. Setup code — chạy ĐÚNG MỘT LẦN cho toàn bộ test export function setup() { // chuẩn bị dữ liệu, đăng nhập lấy token... return { token: 'abc123' }; // dữ liệu trả về được truyền vào default & teardown }

// 3. VU code (default function) — chạy LẶP LẠI liên tục, hàng nghìn/triệu lần export default function (data) { // data ở đây chính là { token: 'abc123' } từ setup() http.get('https://api.example.com/profile', { headers: { Authorization: Bearer ${data.token} }, }); sleep(1); }

// 4. Teardown code — chạy ĐÚNG MỘT LẦN sau khi test kết thúc export function teardown(data) { // dọn dẹp: xóa dữ liệu rác, đóng kết nối... }

Hãy ghi nhớ bảng tần suất này — nó là "linh hồn" của bài học:

Giai đoạnChạy bao nhiêu lầnMục đích
Init (module scope)1 lần / VUImport thư viện, đọc file dữ liệu, khai báo hằng số
setup()Đúng 1 lầnChuẩn bị dữ liệu dùng chung, đăng nhập lấy token
default()Rất nhiều lần (mỗi iteration)Phần thân test — nơi tạo tải thật sự
teardown()Đúng 1 lầnDọn dẹp sau test

Init code — dòng code nằm ngoài mọi hàm

Mọi câu lệnh bạn viết ở "module scope" (ngoài tất cả các hàm) là init code. Nó chạy một lần cho mỗi VU trước khi VU đó bắt đầu chạy iteration đầu tiên. Đây là nơi bắt buộc phải đặt các lệnh import, và cũng là nơi duy nhất k6 cho phép mở/đọc file bằng open().

import { SharedArray } from 'k6/data';

// init code — đọc file CSV người dùng, chia sẻ cho mọi VU const users = new SharedArray('users', function () { return JSON.parse(open('./users.json')); });

Điểm mấu chốt: init code không được phép gọi HTTP request. Nếu bạn thử http.get() ở module scope, k6 sẽ báo lỗi. Lý do là giai đoạn init dùng để "biên dịch và chuẩn bị", không phải để tạo tải.

setup() — chuẩn bị một lần, dùng chung mọi nơi

setup() chạy đúng một lần trước khi bất kỳ VU nào bắt đầu. Ở đây bạn được phép gọi HTTP. Giá trị mà setup() return sẽ được k6 tuần tự hóa (serialize sang JSON) và truyền làm tham số data cho cả default() lẫn teardown().

Hệ quả quan trọng: dữ liệu trả về phải serialize được sang JSON. Bạn không thể trả về một object phức tạp như một kết nối socket hay một hàm — chỉ số, chuỗi, mảng, object thuần.

teardown() — hạ màn và dọn dẹp

teardown() chạy đúng một lần sau khi tất cả VU đã hoàn thành. Nó cũng nhận data từ setup(). Đây là nơi lý tưởng để xóa dữ liệu test đã tạo, gửi thông báo "test xong" lên Slack, hoặc đóng tài nguyên. Lưu ý: nếu setup() bị lỗi (throw exception), k6 sẽ không chạy phần test và cũng không chạy teardown().

Tình huống thực tế

Ví dụ 1: Tiki và bài toán "một triệu lần đăng nhập"

Một kỹ sư QA tại một sàn thương mại điện tử lớn (giả định lấy bối cảnh Tiki) cần load test API xem lịch sử đơn hàng. API này yêu cầu Bearer token. Ở phiên bản đầu tiên, bạn ấy đặt lệnh đăng nhập ngay trong default():

export default function () {
  const loginRes = http.post('https://api.tiki.vn/auth/login', {
    email: 'loadtest@tiki.vn', password: 'secret',
  });
  const token = loginRes.json('access_token');
  http.get('https://api.tiki.vn/orders', {
    headers: { Authorization: Bearer ${token} },
  });
}

Khi chạy với 500 VU trong 10 phút, họ nhận báo động: API /auth/login bị gọi hơn 1,2 triệu lần. Đội backend hoảng loạn vì tưởng bị tấn công brute-force. Tệ hơn, kết quả latency của endpoint /orders bị "nhiễm" bởi thời gian đăng nhập, không còn phản ánh đúng hiệu năng thật.

Giải pháp: chuyển đăng nhập vào setup().

export function setup() {
  const res = http.post('https://api.tiki.vn/auth/login', {
    email: 'loadtest@tiki.vn', password: 'secret',
  });
  return { token: res.json('access_token') };
}

export default function (data) { http.get('https://api.tiki.vn/orders', { headers: { Authorization: Bearer ${data.token} }, }); }

Bài học rút ra: những thứ chỉ cần làm một lần (đăng nhập admin, seed dữ liệu, lấy config) phải nằm trong setup(). Endpoint đăng nhập chỉ còn bị gọi đúng 1 lần, và số liệu của /orders giờ đây sạch sẽ, đáng tin.

Ví dụ 2: MoMo và dữ liệu rác trong staging

Một team QA giả định tại ví điện tử MoMo load test luồng tạo giao dịch nạp tiền trên môi trường staging. Mỗi iteration tạo một bản ghi transaction mới. Sau một đợt test spike 15 phút, staging DB phình thêm 80.000 transaction rác, làm các bài test chức năng sau đó chạy chậm và cho kết quả sai lệch vì bảng dữ liệu quá lớn.

Họ dùng setup() để tạo một "test batch id" đánh dấu, và teardown() để gọi API dọn dẹp toàn bộ theo id đó:

export function setup() {
  const batchId = loadtest-${Date.now()};
  return { batchId };
}

export default function (data) { http.post('https://staging.momo.vn/api/topup', JSON.stringify({ amount: 50000, batch_id: data.batchId, }), { headers: { 'Content-Type': 'application/json' } }); }

export function teardown(data) { // API nội bộ chỉ có trên staging, xóa mọi transaction theo batchId const res = http.del(https://staging.momo.vn/api/test/cleanup?batch=${data.batchId}); console.log(Đã dọn dẹp batch ${data.batchId}, status: ${res.status}); }

Bài học rút ra: setup() + teardown() phối hợp thành một cặp "đánh dấu — dọn dẹp". Nhờ batchId sinh trong setup() được chia sẻ cho mọi VU, việc dọn dẹp trở nên chính xác tuyệt đối, không đụng chạm dữ liệu của người khác trên cùng môi trường staging.

Ví dụ 3: Startup fintech Đông Nam Á và cái bẫy "biến toàn cục"

Một startup fintech ở Singapore viết test và mắc lỗi kinh điển: họ đặt biến ở module scope rồi kỳ vọng nó được chia sẻ giữa các VU.

let counter = 0;           // init code — MỖI VU có bản sao RIÊNG!
export default function () {
  counter++;               // mỗi VU tự đếm của mình, không cộng dồn
  http.get(https://api.demo.sg/item/${counter});
}

Họ tưởng counter sẽ chạy từ 1 đến hàng nghìn. Thực tế, vì init code chạy riêng cho mỗi VU, mỗi VU có counter riêng bắt đầu từ 0. Kết quả là hàng chục VU cùng gọi /item/1, /item/2... trùng lặp nhau. Cách đúng là dùng __VU__ITER (biến k6 cấp sẵn), hoặc chuẩn bị dữ liệu chia sẻ trong setup() trả về.

Bài học rút ra: biến ở init code KHÔNG chia sẻ giữa các VU. Muốn chia sẻ dữ liệu đọc-only cho mọi VU, hãy trả về từ setup() hoặc dùng SharedArray.

Hướng dẫn từng bước

Hãy dựng một kịch bản hoàn chỉnh áp dụng cả bốn giai đoạn:

Bước 1 — Viết init code. Đặt tất cả import và đọc dữ liệu tĩnh ở đầu file.

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = { vus: 20, duration: '1m', };

Bước 2 — Viết setup() để đăng nhập và chuẩn bị. Trả về đúng những gì cần chia sẻ.

export function setup() {
  const loginRes = http.post('https://test-api.k6.io/auth/token/login/', {
    username: 'admin', password: 'secret',
  });
  check(loginRes, { 'setup login 200': (r) => r.status === 200 });
  return {
    token: loginRes.json('access'),
    startedAt: new Date().toISOString(),
  };
}

Bước 3 — Viết default() nhận data. Đây là nơi tạo tải thật.

export default function (data) {
  const res = http.get('https://test-api.k6.io/my/crocodiles/', {
    headers: { Authorization: Bearer ${data.token} },
  });
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(1);
}

Bước 4 — Viết teardown() để đóng màn.

export function teardown(data) {
  console.log(Test bắt đầu lúc: ${data.startedAt});
  // ví dụ: gọi webhook báo test hoàn tất
}

Bước 5 — Chạy và quan sát. Chạy k6 run script.js. Bạn sẽ thấy trong log rằng dòng đăng nhập chỉ xuất hiện một lần ở đầu, và message của teardown() chỉ xuất hiện một lần ở cuối, dù default() đã chạy hàng trăm lần ở giữa.

Lỗi thường gặp & mẹo

Lỗi 1 — Trả về dữ liệu không serialize được từ setup(). Nếu bạn trả về một object có hàm, hoặc một kết nối phức tạp, k6 sẽ mất dữ liệu đó khi truyền sang default(). Chỉ trả về JSON thuần: chuỗi, số, boolean, mảng, object đơn giản.

Lỗi 2 — Gọi HTTP trong init code. Đặt http.get() ngoài hàm sẽ gây lỗi "http requests can't be made in init context". Mọi request phải nằm trong setup, default, hoặc teardown.

Lỗi 3 — Kỳ vọng setup() chạy nhiều lần. setup() chạy đúng một lần cho toàn bộ test, kể cả khi bạn có 1000 VU. Nếu bạn cần mỗi VU có token riêng, dùng biến __VU để chọn tài khoản khác nhau từ một mảng, đừng cố đăng nhập lại trong default().

Lỗi 4 — Quên rằng teardown() không chạy nếu setup() lỗi. Nếu setup() throw exception, k6 dừng luôn, teardown() bị bỏ qua, và dữ liệu rác có thể còn nguyên. Hãy bọc check() và xử lý lỗi cẩn thận trong setup().

Mẹo — Kiểm soát thời gian setup/teardown. Với setup() nặng (seed nhiều dữ liệu), tăng setupTimeout trong options, ví dụ setupTimeout: '3m', tương tự teardownTimeout cho phần dọn dẹp lâu. Mặc định chỉ 60 giây, dễ bị timeout với hệ thống lớn.

Mẹo — Dùng exec.test.abort() thay vì cố tình lỗi. Nếu điều kiện tiên quyết không đạt (ví dụ API health-check fail), dùng import exec from 'k6/execution' rồi exec.test.abort('lý do') để dừng test một cách sạch sẽ.

Bài tập thực hành

  • Cơ bản: Viết một script k6 với setup() gọi tới https://test-api.k6.io/public/crocodiles/ và trả về id của con cá sấu đầu tiên. Trong default(), dùng id đó để GET chi tiết con cá sấu. Chạy với 10 VU trong 30 giây và xác nhận endpoint public chỉ bị gọi 1 lần trong log setup.
  • Trung bình: Thêm teardown() in ra tổng thời gian test đã chạy (tính từ startedAt lưu trong setup() đến thời điểm teardown). Đặt setupTimeoutteardownTimeout là 2 phút.
  • Nâng cao: Mô phỏng ví dụ MoMo — trong setup() sinh một batchId theo Date.now(), cho mỗi VU trong default() gửi POST kèm batchId, và trong teardown() in ra một câu lệnh "DELETE ... WHERE batch = ..." mô phỏng việc dọn dẹp. Giải thích tại sao batchId phải sinh trong setup() chứ không phải trong default().

Tóm tắt

  • k6 có bốn giai đoạn vòng đời: init (1 lần/VU), setup() (1 lần), default() (rất nhiều lần), teardown() (1 lần).
  • Init code là mọi thứ ngoài hàm — đặt import, open() file, hằng số ở đây; KHÔNG gọi HTTP và biến ở đây KHÔNG chia sẻ giữa các VU.
  • setup() chạy đúng một lần, được phép gọi HTTP, dùng để đăng nhập/chuẩn bị; giá trị return phải serialize được sang JSON và sẽ truyền vào default()teardown().
  • teardown() chạy đúng một lần sau test, dùng để dọn dẹp dữ liệu rác — nhưng bị bỏ qua nếu setup() lỗi.
  • Ba bài học từ thực tế: đưa đăng nhập vào setup() để tránh gọi API triệu lần và làm sạch số liệu; dùng cặp setup/teardown với batchId để dọn dẹp chính xác; và nhớ rằng biến init không chia sẻ giữa VU.
Nắm vững lifecycle này, bài test k6 của bạn sẽ vừa đúng, vừa sạch, vừa chuyên nghiệp — sẵn sàng cho những kịch bản phức tạp hơn ở các bài tiếp theo.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi