NỘI DUNG BÀI HỌC
✅ Làm chủ Setup & Teardown: Sử dụng
yield để tách biệt phần "chuẩn bị" (ví dụ: đăng nhập, tạo data) khỏi phần "dọn dẹp" (ví dụ: đăng xuất, xóa data) một cách an toàn. ✅ TỐI ƯU HÓA TỐC ĐỘ: Hiểu sâu sắc 4 cấp độ
scope của Fixture (function, class, module, session) để quyết định khi nào cần cô lập (isolation) và khi nào cần hiệu suất (performance). ✅ Tổ chức và Lọc Test Case: Thành thạo việc "dán nhãn" (Markers) cho các test (ví dụ:
smoke, regression) và học cách chạy có chọn lọc (pytest -m "smoke"). 🧠 I. Quản lý Setup/Teardown với Fixtures
1. Khái niệm: Fixture là gì?
Hãy tưởng tượng bạn là một đầu bếp. Mỗi món ăn (test case) cần một số nguyên liệu đã sơ chế (ví dụ: page đã đăng nhập, data test đã được tạo). Một Fixture chính là người "phụ bếp" (helper) chuyên chuẩn bị sẵn các nguyên liệu đó cho bạn.
-
Ví dụ thực tế: Chuẩn bị đối tượng
pageđã đăng nhập, tạo một user mới trong CSDL trước khi test, kết nối API, hoặc dọn dẹp (đăng xuất, xóa data đã tạo) sau khi test. -
Vấn đề: Bạn phải lặp lại code đăng nhập ở mọi test case cần đăng nhập. Nếu quy trình đăng nhập thay đổi (thêm 1 bước, đổi locator), bạn phải đi sửa ở 100 nơi.
-
Hậu quả: Code lặp lại (vi phạm nguyên tắc DRY - Don't Repeat Yourself), khó bảo trì, tốn thời gian, và dễ sai sót.
2. Giải pháp: @pytest.fixture và yield
Pytest cho phép bạn định nghĩa các hàm "chuẩn bị" bằng decorator @pytest.fixture. Từ khóa yield là mấu chốt:
-
Code trước
yieldlà Setup (chạy trước khi test bắt đầu). -
yieldtrả về đối tượng (ví dụ:pageđã đăng nhập) cho test case sử dụng. -
Test case sẽ chạy ngay sau
yield. -
Code sau
yieldlà Teardown (chạy sau khi test xong, bất kể test đó pass hay fail).
Ví dụ 1: Fixture page cơ bản (Bạn đang dùng mà không biết!)
Khi bạn viết def test_example(page):, page ở đây chính là một fixture do pytest-playwright cung cấp sẵn. Nó tự động làm 4 việc: (Setup) Mở trình duyệt, tạo context, tạo page MỚI ➝ (Yield) Trả về page cho bạn ➝ (Teardown) Đóng page, context, trình duyệt.
Ví dụ 2: Fixture "Đăng nhập" tùy chỉnh
Hãy tạo một fixture logged_in_page chuyên lo việc đăng nhập.
Ví dụ Automation:
import pytest
from playwright.sync_api import Page, expect
# 1. ĐỊNH NGHĨA FIXTURE
# Fixture này "phụ thuộc" vào fixture 'page' (bằng cách nhận 'page' làm tham số)
@pytest.fixture
def logged_in_page(page: Page):
# --- PHẦN 1: SETUP (Chạy trước test) ---
print("\n[Fixture Setup]: Đang đăng nhập...")
page.goto("https://the-internet.herokuapp.com/login")
page.locator("#username").fill("tomsmith")
page.locator("#password").fill("SuperSecretPassword!")
page.get_by_role("button", name="Login").click()
# Đảm bảo đăng nhập thành công
expect(page.locator("#flash")).to_contain_text("You logged in!")
# --- PHẦN 2: YIELD ---
# Giao 'page' (đã đăng nhập) cho test case
yield page
# --- PHẦN 3: TEARDOWN (Chạy sau test) ---
print("\n[Fixture Teardown]: Đang đăng xuất...")
page.get_by_role("link", name="Logout").click()
expect(page.locator("#flash")).to_contain_text("You logged out!")
# 2. SỬ DỤNG FIXTURE
# ❌ SAI: (Cách cũ - lặp code)
def test_logout_button_visible_OLD_WAY(page: Page):
# Lặp lại toàn bộ code login...
page.goto("https://the-internet.herokuapp.com/login")
page.locator("#username").fill("tomsmith")
page.locator("#password").fill("SuperSecretPassword!")
page.get_by_role("button", name="Login").click()
# Assert
expect(page.get_by_role("link", name="Logout")).to_be_visible()
# ✅ ĐÚNG: (Dùng fixture)
# Chỉ cần "yêu cầu" fixture 'logged_in_page' làm tham số
def test_logout_button_visible(logged_in_page: Page):
# ARRANGE: Fixture 'logged_in_page' đã lo
# ACT & ASSERT
# 'logged_in_page' đã ở trạng thái đăng nhập thành công
print("[Test Body]: Đang kiểm tra nút Logout...")
expect(logged_in_page.get_by_role("link", name="Logout")).to_be_visible()
def test_welcome_message(logged_in_page: Page):
# ARRANGE: Fixture 'logged_in_page' cũng lo
# ACT & ASSERT
print("[Test Body]: Đang kiểm tra lời chào...")
expect(logged_in_page.locator("#flash")).to_contain_text("You logged in!")
🧠 II. (NÂNG CAO) Giải mã Scope của Fixture: Khi nào dùng gì?
1. Khái niệm: Scope là gì?
Scope (Phạm vi) quyết định tần suất một fixture được tạo ra (setup) và phá hủy (teardown). Nó là sự cân bằng giữa Tính Cô Lập (an toàn nhất) và Tốc Độ (nhanh nhất).
Hãy tưởng tượng bạn chuẩn bị dụng cụ cho đầu bếp (test case):
-
scope="function": Đưa cho mỗi đầu bếp một con dao mới tinh, dùng xong vứt đi. Rất an toàn, không bị lẫn mùi, nhưng tốn kém. -
scope="session": Đưa cho tất cả đầu bếp dùng chung một cái lò nướng đã bật sẵn từ sáng. Rất nhanh, không phải chờ lò nóng, nhưng nếu ai đó làm bẩn lò thì người sau bị ảnh hưởng.
2. Phân tích chi tiết 4 cấp độ Scope
A. scope="function" (Mặc định)
-
Tần suất: Chạy lại Setup/Teardown cho MỖI HÀM TEST.
-
Analogy (Đời sống): Một chiếc bát ăn cơm dùng một lần. Mỗi "lần ăn" (test case) bạn lại lấy một cái bát mới tinh. Ăn xong là vứt (teardown).
- Cú pháp:
import pytest from playwright.sync_api import Page, expect # Bạn có thể viết @pytest.fixture() hoặc @pytest.fixture(scope="function") @pytest.fixture(scope="function") def logged_in_page(page: Page): # --- 1. SETUP (Chạy trước mỗi test) --- print("\n[Fixture function]: Đang đăng nhập...") page.goto("https://www.saucedemo.com/") page.locator("#user-name").fill("standard_user") page.locator("#password").fill("secret_sauce") page.locator("#login-button").click() expect(page).to_have_url("https://www.saucedemo.com/inventory.html") # --- 2. YIELD --- yield page # Giao page đã đăng nhập cho test # --- 3. TEARDOWN (Chạy sau mỗi test) --- print("\n[Fixture function]: Đang đăng xuất...") page.get_by_role("button", name="Open Menu").click() page.get_by_role("link", name="Logout").click() expect(page).to_have_url("https://www.saucedemo.com/") # CÁCH DÙNG def test_add_item_to_cart(logged_in_page: Page): # 'logged_in_page' là page đã login do fixture cung cấp logged_in_page.locator("[data-test='add-to-cart-sauce-labs-backpack']").click() expect(logged_in_page.locator(".shopping_cart_badge")).to_have_text("1") def test_view_item_detail(logged_in_page: Page): # Fixture sẽ chạy lại TỪ ĐẦU cho test này (login lại) logged_in_page.locator("#item_4_title_link").click() expect(logged_in_page.locator(".inventory_details_name")).to_have_text("Sauce Labs Backpack") -
Khi nào dùng trong Auto Test:
-
Đây là lựa chọn AN TOÀN và PHỔ BIẾN NHẤT.
-
Khi bạn cần sự cô lập tuyệt đối. Mỗi test case phải bắt đầu từ một trạng thái sạch sẽ, không bị ảnh hưởng bởi test case trước đó.
-
Ví dụ 1 (Playwright): Fixture
pagemặc định củapytest-playwrightdùng scope này. Mỗi test case (def test_abc(page):) nhận được một tab (page) hoàn toàn mới, không có cookie, không cache, không lịch sử từ test trước. -
Ví dụ 2 (Tạo Data): Một fixture
create_new_usertạo ra một user với email ngẫu nhiên trong CSDL. Bạn muốn mỗi test đăng ký (test_register_success,test_register_duplicate_email) đều chạy với một user mới hoàn toàn, nên bạn dùngscope="function".
-
B. scope="class"
-
Tần suất: Chạy Setup 1 LẦN trước test method đầu tiên trong Class, và Teardown 1 LẦN sau test method cuối cùng trong Class.
-
Analogy (Đời sống): Một bàn làm việc nhóm. Cả nhóm (class) vào phòng họp (setup) lúc 9h sáng. Mọi người (test methods) dùng chung cái bàn đó để làm việc. 5h chiều họp xong, cả nhóm dọn dẹp bàn và rời đi (teardown).
-
Khi nào dùng trong Auto Test:
-
Khi bạn có một nhóm test (trong 1 class) đều yêu cầu chung một bước setup tốn kém và các test đó không phá hỏng trạng thái của nhau.
-
Ví dụ 1 (Login 1 lần): Bạn có
class TestProfileSettings:chứa 10 test nhỏ (test_update_avatar,test_update_bio,test_change_address...). Tất cả 10 test này đều yêu cầu user phải đăng nhập.-
Dùng
scope="function": Sẽ phải Login 10 lần và Logout 10 lần ➝ Rất chậm! -
Dùng
scope="class": Tạo 1 fixturelogged_in_page_class_scope(scope="class"). Nó sẽ Login 1 LẦN, sau đó cả 10 test case cùng chạy trênpageđã đăng nhập đó. Cuối cùng, nó Logout 1 LẦN. ➝ Nhanh hơn rất nhiều. -
Lưu ý: Để dùng, bạn phải đánh dấu class với
@pytest.mark.usefixtures("<tên_fixture>"). Các method trong class sẽ không nhận fixture làm tham số trực tiếp, mà sẽ dùngpage(hoặcself.page) đã được fixture đó "biến đổi". -
Cú pháp:
import pytest from playwright.sync_api import Page, expect @pytest.fixture(scope="class") def class_scoped_login(page: Page): # Nhận 'page' (function scope) để thiết lập # --- 1. SETUP (Chạy 1 lần trước test đầu tiên của class) --- print("\n[Fixture class]: Đang đăng nhập 1 LẦN...") page.goto("https://www.saucedemo.com/") page.locator("#user-name").fill("standard_user") page.locator("#password").fill("secret_sauce") page.locator("#login-button").click() expect(page).to_have_url("https://www.saucedemo.com/inventory.html") # --- 2. YIELD --- yield page # Giao page đã đăng nhập cho cả class # --- 3. TEARDOWN (Chạy 1 lần sau test cuối cùng của class) --- print("\n[Fixture class]: Đang đăng xuất 1 LẦN...") page.get_by_role("button", name="Open Menu").click() page.get_by_role("link", name="Logout").click() # CÁCH DÙNG @pytest.mark.usefixtures("class_scoped_login") class TestInventory: def test_add_item(self, page: Page): # 'page' ở đây chính là 'page' đã được fixture 'class_scoped_login' đăng nhập print("\n[Test 1]: Thêm item...") page.locator("[data-test='add-to-cart-sauce-labs-backpack']").click() expect(page.locator(".shopping_cart_badge")).to_have_text("1") def test_remove_item(self, page: Page): # 'page' này VẪN LÀ PAGE ĐÓ, nó KHÔNG login lại # Nó vẫn giữ nguyên trạng thái từ test_add_item (giỏ hàng có "1") print("\n[Test 2]: Xóa item...") page.locator("[data-test='remove-sauce-labs-backpack']").click() expect(page.locator(".shopping_cart_badge")).not_to_be_visible()
-
-
C. scope="module"
-
Tần suất: Chạy Setup 1 LẦN trước test đầu tiên trong file
.py, và Teardown 1 LẦN sau test cuối cùng trong file.py. -
Analogy (Đời sống): Kết nối Wi-Fi cho một tầng lầu. Khi người đầu tiên của tầng lầu (module) đến, bộ phát Wi-Fi (fixture) được bật lên (setup). Mọi người trong tầng (all functions/classes in the file) dùng chung Wi-Fi đó. Khi người cuối cùng rời đi, bộ phát được tắt (teardown).
- Cú pháp:
import pytest from playwright.sync_api import Browser, BrowserContext, Page, expect @pytest.fixture(scope="module") def shared_context(browser: Browser): # --- 1. SETUP (Chạy 1 lần trước test đầu tiên trong file) --- print(f"\n[Fixture module]: Tạo context chung...") # Ví dụ: tạo context với storage state đã login sẵn context = browser.new_context(storage_state="auth.json") # --- 2. YIELD --- yield context # --- 3. TEARDOWN (Chạy 1 lần sau test cuối cùng trong file) --- print(f"\n[Fixture module]: Đóng context chung...") context.close() # CÁCH DÙNG # Các test này sẽ dùng chung 1 context, nhưng vẫn có thể tạo page mới từ context đó # (Cần cấu hình lại fixture 'page' để nó dùng 'shared_context') # Hoặc đơn giản là dùng context đó trực tiếp: def test_profile_page(shared_context: BrowserContext): page = shared_context.new_page() # Tạo page mới từ context đã login print("\n[Test 1]: Kiểm tra trang profile...") page.goto("https://github.com/profile") # Giả sử auth.json là của Github expect(page.locator(".user-profile-name")).to_be_visible() page.close() def test_settings_page(shared_context: BrowserContext): page = shared_context.new_page() # Tạo page mới TỪ CÙNG CONTEXT print("\n[Test 2]: Kiểm tra trang settings...") page.goto("https://github.com/settings/profile") expect(page.locator("#user_profile_name")).to_be_visible() page.close() -
Khi nào dùng trong Auto Test:
-
Khi bạn cần một tài nguyên cho toàn bộ file test, nhưng không muốn ảnh hưởng đến các file test khác.
-
Ví dụ 1 (Kết nối DB): File
test_user_database.pychứa 50 test case cần truy vấn CSDL. Bạn tạo 1 fixturedb_connection(scope="module") để Mở kết nối CSDL 1 LẦN lúc bắt đầu file, và Đóng kết nối 1 LẦN lúc kết thúc file, thay vì mở/đóng 50 lần.
-
D. scope="session"
-
Tần suất: Chạy Setup 1 LẦN DUY NHẤT khi bắt đầu toàn bộ phiên chạy
pytest, và Teardown 1 LẦN DUY NHẤT khi tất cả test trong mọi file đã chạy xong. -
Analogy (Đời sống): Xây dựng tòa nhà. Bạn xây tòa nhà (fixture) 1 LẦN (setup). Sau đó, hàng ngàn người (test cases) ra vào, sử dụng tòa nhà đó trong nhiều năm. Hàng chục năm sau, khi tòa nhà không còn được sử dụng nữa, nó mới bị phá dỡ (teardown).
- Cú pháp:
import pytest import json # Fixture này thường được đặt trong file conftest.py # để tất cả các file test đều có thể dùng @pytest.fixture(scope="session") def app_config(): # --- 1. SETUP (Chạy 1 lần duy nhất khi bắt đầu phiên test) --- print("\n[Fixture session]: Đọc file config.json 1 LẦN...") with open("config.json", "r") as f: config = json.load(f) # --- 2. YIELD --- yield config # --- 3. TEARDOWN (Chạy 1 lần duy nhất khi kết thúc) --- print(f"\n[Fixture session]: Kết thúc phiên test. Tạm biệt!") # Thường không cần teardown gì khi đọc file config # CÁCH DÙNG (Giả sử file config.json có {"base_url": "https://www.saucedemo.com/"}) # File: test_login.py def test_login_page_title(page, app_config): # 'app_config' được inject vào base_url = app_config["base_url"] page.goto(base_url) expect(page).to_have_title("Swag Labs") # File: test_inventory.py def test_inventory_url(page, app_config): # 'app_config' cũng được inject vào base_url = app_config["base_url"] # (Giả sử cần login...) page.goto(f"{base_url}/inventory.html") # Dùng config expect(page.locator(".title")).to_have_text("Products") -
Khi nào dùng trong Auto Test:
-
Cho những tài nguyên siêu tốn kém để tạo ra, và thường là chỉ đọc (read-only), hoặc được dùng chung bởi tất cả các test.
-
Ví dụ 1 (Đọc file Config): Tạo một fixture
config(scope="session") để đọc fileconfig.json(chứa URL, username, password) 1 LẦN duy nhất khi bắt đầu. Mọi test case, mọi file test sau đó đều có thể lấyconfignày ra dùng mà không cần đọc lại file. -
Ví dụ 2 (Khởi tạo Browser): (Nếu bạn tự build) Fixture
browsercủapytest-playwrightthực chất dùngscope="session". Nó khởi động trình duyệt (vd: Chromium) 1 LẦN duy nhất, và tất cả cácpage(scope="function") đều được "sinh ra" từ cáibrowserduy nhất đó. Khi tất cả test chạy xong, nó mới đóng trình duyệt. Đây là lý do Playwright chạy rất nhanh. -
Ví dụ 3 (Tạo Master Data): Tạo một "Super Admin" user (scope="session") 1 LẦN để dùng cho TẤT CẢ các test (chỉ dùng để login, không thay đổi).
-
3. Bảng tổng kết nhanh
| Scope | Tần suất chạy Setup/Teardown | Analogy (Phép ví von) | Ví dụ Auto Test |
function |
Cho mỗi hàm test | Bát ăn 1 lần (sạch sẽ, cô lập) | page (để mỗi test có tab mới) |
class |
1 lần cho mỗi class test | Bàn làm việc nhóm (dùng chung cho nhóm) | Login 1 lần cho class TestProfile |
module |
1 lần cho mỗi file .py | Wi-Fi tầng lầu (dùng chung cho file) | Kết nối CSDL cho file test_db.py |
session |
1 lần DUY NHẤT cho cả phiên | Tòa nhà (dùng chung cho TẤT CẢ) | Đọc file config.json, khởi động browser |
🧠 III. Gom nhóm & Lọc Test với Markers
1. Khái niệm: Marker là gì?
Hãy tưởng tượng bạn có một thùng 1000 hồ sơ (test case). Markers giống như những chiếc "nhãn dán" (labels) nhiều màu. Bạn dán nhãn MÀU ĐỎ (smoke) cho 10 hồ sơ "Khẩn cấp" và nhãn MÀU XANH (regression) cho tất cả 1000 hồ sơ.
-
Ví dụ thực tế: Phân loại test:
smoke(test cơ bản, quan trọng),regression(test hồi quy),login,payment,ui,api. -
Vấn đề: Bạn có 1000 test. Bạn chỉ muốn chạy 10 test
smokeđể kiểm tra nhanh hệ thống có "sống" không trước khi deploy, hoặc chỉ muốn chạy các test liên quan đếnpayment. -
Hậu quả: Phải chạy toàn bộ 1000 test, tốn thời gian, CI/CD chậm chạp.
2. Giải pháp: @pytest.mark
Pytest cho phép "dán nhãn" bất kỳ test nào bằng decorator @pytest.mark.<tên_nhãn>.
Ví dụ 1: Các Marker Tích hợp sẵn
-
@pytest.mark.skip(reason=...): Luôn bỏ qua test này. -
@pytest.mark.skipif(condition, reason=...): Bỏ qua nếu điều kiện đúng. -
@pytest.mark.xfail(reason=...): Đánh dấu là "dự kiến sẽ thất bại" (ví dụ: test cho một bug đã được báo cáo nhưng chưa fix).
Ví dụ Automation:
import pytest
import sys
@pytest.mark.skip(reason="Chức năng này đang được bảo trì, sẽ chạy sau")
def test_new_feature_B(page):
# Code sẽ không bao giờ chạy
pass
@pytest.mark.skipif(sys.platform == "win32", reason="Test này chỉ chạy trên Linux/MacOS")
def test_linux_specific_feature(page):
# Sẽ bị skip nếu bạn chạy trên Windows
pass
@pytest.mark.xfail(reason="Bug #1234 chưa được fix, test này dự kiến sẽ fail")
def test_bug_1234_reproduction(page):
page.goto("...")
# Code test cho bug
expect(page.locator(".buggy-element")).to_be_visible() # Giả sử element này không có
Ví dụ 2: Markers Tùy chỉnh (Phần mạnh nhất)
Bạn có thể tự tạo bất kỳ nhãn nào bạn muốn.
Ví dụ Automation:
import pytest
@pytest.mark.smoke # Nhãn 'smoke'
@pytest.mark.login # Có thể có nhiều nhãn
def test_login_success(page):
# ... code test login thành công ...
pass
@pytest.mark.regression
@pytest.mark.login
def test_login_wrong_password(page):
# ... code test login sai pass ...
pass
@pytest.mark.smoke
@pytest.mark.search
def test_search_product(page):
# ... code test tìm kiếm ...
pass
3. Cách chạy Test với Markers (Từ Terminal)
Sau khi dán nhãn, bạn dùng cờ -m (marker) trong terminal để lọc:
-
Chỉ chạy
smoke:pytest -m smoke-
(Sẽ chạy
test_login_successvàtest_search_product)
-
-
Chỉ chạy
login:pytest -m login-
(Sẽ chạy
test_login_successvàtest_login_wrong_password)
-
-
Chạy tất cả TRỪ
smoke:pytest -m "not smoke"-
(Sẽ chạy
test_login_wrong_password)
-
-
Chạy
smokeVÀlogin:pytest -m "smoke and login"-
(Chỉ chạy
test_login_success)
-
-
Chạy
smokeHOẶCsearch:pytest -m "smoke or search"-
(Chạy cả 3 test)
-
4. Đăng ký Markers (Rất quan trọng)
Để tránh Pytest báo "Warning: Unknown marker...", bạn PHẢI đăng ký các nhãn tùy chỉnh trong file pytest.ini (tạo file này ở thư mục gốc của project).
Nội dung file pytest.ini:
[pytest]
markers =
smoke: Các test case quan trọng (critical path)
regression: Toàn bộ test case hồi quy
login: Các test liên quan đến đăng nhập
search: Các test liên quan đến tìm kiếm
payment: Các test về thanh toán
🧩 IV. Bài tập Thực hành Tổng hợp
Trang web thực hành: https://the-internet.herokuapp.com/login và https://www.saucedemo.com/
Bài 1: Refactor với Fixture
-
Trang:
https://www.saucedemo.com/(Trang này không tự logout sau khi test) -
Yêu cầu:
-
Tạo một file test mới (ví dụ:
test_saucedemo.py). -
Tạo một fixture tên
logged_in_page(scope="function"). -
Setup: Fixture này phải truy cập trang, điền user (
standard_user) và pass (secret_sauce), sau đó click Login. -
Yield: Trả về
pageđã đăng nhập. -
Teardown: (Quan trọng) Fixture phải click vào menu (burger button), click "Logout", và
expectđể đảm bảo đã quay về trang login. -
Viết 2 test case sử dụng fixture này:
-
test_add_to_cart: Kiểm tra việc thêm 1 sản phẩm vào giỏ hàng (click "Add to cart" và expect số trên giỏ hàng là "1"). -
test_view_product_detail: Click vào tên 1 sản phẩm vàexpecttrang chi tiết có nút "Back to products".
-
-
Bài 2: Đánh dấu (Markers) cho Test Case
-
Trang: Lấy các test case ở Bài 1 và các test case cũ.
-
Yêu cầu:
-
Tạo file
pytest.iniở gốc project. Đăng ký 3 marker:smoke,regression,login. -
Gắn marker cho các test case:
-
Gắn
@pytest.mark.smokevà@pytest.mark.logincho test login thành công. -
Gắn
@pytest.mark.regressionvà@pytest.mark.logincho test login thất bại. -
Gắn
@pytest.mark.smokevà@pytest.mark.regressionchotest_add_to_cart(Bài 1). -
Gắn
@pytest.mark.regressionchotest_view_product_detail(Bài 1).
-
-
Thực hành chạy trên terminal:
-
pytest -m smoke(Kiểm tra xem có 2 test chạy không?) -
pytest -m login(Kiểm tra xem có 2 test chạy không?) -
pytest -m "smoke and not login"(Kiểm tra xem có 1 test chạy không?)
-
-
