Sổ tay học tập

Giai đoạn 2: OOP

Chưa họcTrung cấp45 phút đọc99 đoạn code
Mục lục (59)

Thời lượng: ~30 giờ, 8 tuần ở nhịp 4h/tuần
Điều kiện: xong Giai đoạn 1 — đặc biệt là Bài 6 (method, static) và Bài 7 (primitive vs reference)
Kết quả mong đợi: tự viết được Page Object và BaseTest từ đầu, không copy mẫu; đọc hiểu mọi dòng trong framework của người khác


Giai đoạn này khác Giai đoạn 1 ở chỗ nào

Giai đoạn 1 dạy cách viết những câu lệnh chạy được. Giai đoạn 2 dạy cách tổ chức chúng.

Đây là ranh giới thật giữa "người chạy được script" và "người viết được framework". Nó cũng là giai đoạn có nhiều khái niệm trừu tượng nhất — nhiều người học thuộc định nghĩa mà không dùng được. Cách chống lại điều đó: mỗi khái niệm trong tài liệu này đều được nối trực tiếp vào một dòng code Selenium thật mà Linh sẽ viết.

Ba câu hỏi mà hết giai đoạn này phải trả lời được không cần suy nghĩ:

  1. extends BaseTest làm gì?
  2. Tại sao viết được WebDriver driver = new ChromeDriver(); — hai kiểu khác nhau ở hai bên dấu =?
  3. Tại sao gọi được .enterEmail(...).enterPassword(...) nối liền nhau?

Cuối tài liệu (Bài 9) sẽ thấy cả ba gộp lại thành một Page Object hoàn chỉnh.

Lịch học gợi ý

Tuần Nội dung Giờ
1 Bài 1 — Class và object 4h
2 Bài 2 — Constructor · Bài 3 — Encapsulation 4h
3 Bài 4 — Inheritance 4h
4 Bài 5 — Polymorphism 4h
5 Bài 6 — Abstract class và interface 4h
6 Bài 7 — static, final, Object · Bài 8 — Package 4h
7 Bài 9 — Page Object Model (phần 1) 3h
8 Bài 9 — Page Object Model (phần 2) + tổng hợp 3h

Quy tắc vẫn như cũ: gõ lại code, làm bài tập trước khi xem đáp án, mỗi buổi mở IntelliJ.


Bài 1 — Class và object

1.1 Bản thiết kế và thực thể

Class là bản thiết kế. Object là thứ được tạo ra từ bản thiết kế đó.

public class TestCase {
    String id;
    String tenChucNang;
    String ketQua;
    int thoiGianGiay;
}

Class này chưa phải là một test case cụ thể nào — nó chỉ nói "một test case gồm 4 thông tin". Muốn có test case thật:

TestCase tc1 = new TestCase();
tc1.id = "TC001";
tc1.tenChucNang = "Login";
tc1.ketQua = "PASS";
tc1.thoiGianGiay = 12;

TestCase tc2 = new TestCase();
tc2.id = "TC002";
tc2.ketQua = "FAIL";

System.out.println(tc1.id);   // TC001
System.out.println(tc2.id);   // TC002

new TestCase() tạo một object mới trong bộ nhớ và trả về địa chỉ của nó. tc1 và tc2 là hai object độc lập — sửa cái này không ảnh hưởng cái kia. Đây chính là biến reference ở Giai đoạn 1 Bài 7, giờ dùng với class của mình.

Dấu . nghĩa là "đi vào bên trong object này".

1.2 Field và method của object

Biến khai báo trong class gọi là field (hoặc thuộc tính, instance variable). Method dùng field đó:

public class TestCase {
    String id;
    String ketQua;
    int thoiGianGiay;

    public boolean laPass() {
        return "PASS".equals(ketQua);
    }

    public String moTa() {
        return id + " (" + thoiGianGiay + "s): " + ketQua;
    }
}
TestCase tc = new TestCase();
tc.id = "TC001";
tc.ketQua = "PASS";
tc.thoiGianGiay = 12;

System.out.println(tc.laPass());   // true
System.out.println(tc.moTa());     // TC001 (12s): PASS

Chú ý: laPass() và moTa() không có static, và bên trong chúng dùng ketQua trực tiếp mà không cần tham số. Lý do: method không static luôn thuộc về một object cụ thể, nên nó biết field của object đó.

Đây là lời giải thích đầy đủ cho static ở Giai đoạn 1 Bài 6.4. tc.laPass() — Java biết ketQua nào cần đọc, vì Linh đã nói rõ object nào ở trước dấu ..

1.3 Giá trị mặc định của field

public class TestCase {
    String id;          // null
    int thoiGianGiay;   // 0
    boolean daChay;     // false
    double tiLe;        // 0.0
}

Khác với biến cục bộ trong method — biến cục bộ bắt buộc phải gán trước khi dùng, còn field thì tự có giá trị mặc định. Hệ quả: tc.id.length() ném NPE nếu chưa gán id. Bài 2 sẽ giải quyết bằng constructor.

1.4 Một class, một trách nhiệm

public class TestCase { ... }       // đại diện một test case
public class LoginPage { ... }      // đại diện màn hình login
public class ExcelReader { ... }    // đọc file Excel
public class TestUtils { ... }      // các hàm tiện ích static

Đặt tên class: PascalCase, là danh từ, số ít. Nếu tên class có chữ "And" (LoginAndSearchPage) thì gần như chắc chắn nó đang làm hai việc và cần tách.

Một file .java một class public, và tên file phải trùng tên class: TestCase.java chứa class TestCase.

Bài tập 1

1.1 Viết class Bug với các field: id (String), tieuDe (String), severity (String), soNgayMo (int), daFix (boolean). Thêm method boolean canEscalate() trả về true nếu severity là Critical và mở quá 7 ngày. Tạo 3 object và in kết quả.

1.2 Thêm method String moTaNgan() trả về dạng #288-101 [Critical] Sai mật khẩu vẫn vào được.

1.3 Viết class TestSuite có field String ten và int soCase, int soPass. Thêm method double tiLePass() — chú ý bẫy chia số nguyên và bẫy chia cho 0 ở Giai đoạn 1.

1.4 Đoán kết quả:

Bug b1 = new Bug();
b1.id = "#101";
Bug b2 = b1;
b2.id = "#999";
System.out.println(b1.id);

1.5 Tại sao đoạn này crash?

Bug b = new Bug();
System.out.println(b.tieuDe.length());

Đáp án bài 1

1.1

public class Bug {
    String id;
    String tieuDe;
    String severity;
    int soNgayMo;
    boolean daFix;

    public boolean canEscalate() {
        return "Critical".equals(severity) && soNgayMo > 7 && !daFix;
    }
}

Thêm && !daFix là hợp lý — bug đã fix thì không escalate. Nếu Linh nghĩ ra điều này mà đề bài không nói, đó là tư duy QA đúng chỗ.

1.2

public String moTaNgan() {
    return id + " [" + severity + "] " + tieuDe;
}

1.3

public class TestSuite {
    String ten;
    int soCase;
    int soPass;

    public double tiLePass() {
        if (soCase == 0) return 0.0;
        return soPass * 100.0 / soCase;
    }
}

if (soCase == 0) là guard clause chống chia cho 0, và 100.0 chống bẫy chia số nguyên. Hai bẫy của Giai đoạn 1, cùng một dòng.

1.4 In #999. b2 = b1 sao chép địa chỉ, nên cả hai trỏ vào cùng một object. Đúng như bài array ở Giai đoạn 1 Bài 7.1 — object cũng là reference.

1.5 NPE. tieuDe chưa gán nên là null, gọi .length() trên null là crash. Field có giá trị mặc định, nhưng với reference thì mặc định là null — không phải chuỗi rỗng.


Bài 2 — Constructor

2.1 Vấn đề constructor giải quyết

Cách ở Bài 1 có ba nhược điểm:

TestCase tc = new TestCase();
tc.id = "TC001";
tc.ketQua = "PASS";
// quên gán thoiGianGiay → object không hoàn chỉnh

Dài dòng, dễ quên field, và có một khoảng thời gian object tồn tại ở trạng thái không hợp lệ.

2.2 Cú pháp

public class TestCase {
    String id;
    String ketQua;
    int thoiGianGiay;

    public TestCase(String id, String ketQua, int thoiGianGiay) {
        this.id = id;
        this.ketQua = ketQua;
        this.thoiGianGiay = thoiGianGiay;
    }
}
TestCase tc = new TestCase("TC001", "PASS", 12);

Constructor có ba đặc điểm nhận dạng:

  • Tên trùng tên class, phân biệt hoa thường
  • Không có kiểu trả về — không phải void, mà không có gì cả
  • Được gọi tự động khi new

2.3 this

this nghĩa là "object hiện tại". Nó cần thiết khi tên tham số trùng tên field:

public TestCase(String id) {
    this.id = id;    // this.id là FIELD, id là THAM SỐ
}

Viết id = id; thì Java hiểu là gán tham số cho chính nó — không lỗi biên dịch, nhưng field vẫn null. Bug im lặng kinh điển. IntelliJ có cảnh báo mờ cho trường hợp này, để ý dòng gạch vàng.

Ngoài constructor, this cũng dùng được trong method — thường không cần thiết, nhưng có một chỗ dùng rất quan trọng ở Bài 9 (return this để nối method).

2.4 Constructor mặc định — và cái bẫy

Nếu class không khai báo constructor nào, Java tự tạo cho một constructor rỗng không tham số. Đó là lý do Bài 1 gọi new TestCase() được.

Nhưng ngay khi khai báo một constructor có tham số, constructor rỗng đó mất:

TestCase tc = new TestCase();   // LỖI biên dịch nếu class chỉ có constructor 3 tham số

Muốn có cả hai thì phải viết cả hai.

2.5 Overload constructor và gọi chuỗi

public class TestCase {
    String id;
    String ketQua;
    int thoiGianGiay;

    public TestCase(String id, String ketQua, int thoiGianGiay) {
        this.id = id;
        this.ketQua = ketQua;
        this.thoiGianGiay = thoiGianGiay;
    }

    public TestCase(String id) {
        this(id, "NOT_RUN", 0);      // gọi constructor bên trên
    }
}

this(...) gọi một constructor khác của cùng class, và phải là câu lệnh đầu tiên. Cùng nguyên tắc DRY như overload method ở Giai đoạn 1 Bài 6.4: chỉ một chỗ chứa logic thật.

2.6 Constructor trong Page Object — dùng thật

Đây là dạng Linh sẽ viết hàng chục lần:

public class LoginPage {
    WebDriver driver;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
    }
}
LoginPage loginPage = new LoginPage(driver);

Mỗi Page Object cần biết nó điều khiển browser nào. Constructor là chỗ nhận driver và lưu lại thành field, để mọi method trong class dùng được. Không có constructor thì mỗi method phải nhận driver làm tham số — lặp lại vô nghĩa.

Bài tập 2

2.1 Viết constructor đầy đủ cho class Bug ở bài 1.1. Tạo 3 object bằng constructor.

2.2 Thêm constructor overload chỉ nhận id và tieuDe, mặc định severity = "Minor", soNgayMo = 0, daFix = false. Dùng this(...).

2.3 Đoạn này sai ở đâu, và biểu hiện là gì?

public class Bug {
    String id;
    public Bug(String id) {
        id = id;
    }
}

2.4 Tại sao đoạn này lỗi biên dịch?

public class TestSuite {
    String ten;
    public TestSuite(String ten) { this.ten = ten; }
}
// ...
TestSuite s = new TestSuite();

2.5 Viết class TestConfig với constructor nhận browser, baseUrl, timeout, và constructor rỗng dùng giá trị mặc định "chrome", "http://localhost:3000", 10.

Đáp án bài 2

2.1

public Bug(String id, String tieuDe, String severity, int soNgayMo, boolean daFix) {
    this.id = id;
    this.tieuDe = tieuDe;
    this.severity = severity;
    this.soNgayMo = soNgayMo;
    this.daFix = daFix;
}

Mẹo: trong IntelliJ bấm Alt+Insert → Constructor → chọn field, nó sinh ra đoạn này. Nhưng gõ tay vài lần trước đã, để hiểu nó sinh ra cái gì.

2.2

public Bug(String id, String tieuDe) {
    this(id, tieuDe, "Minor", 0, false);
}

2.3 Thiếu this. Không lỗi biên dịch, nhưng this.id vẫn null sau khi tạo object — câu lệnh id = id chỉ gán tham số cho chính nó. Biểu hiện: NPE ở một chỗ khác hoàn toàn, rất khó truy nguyên. Đây là lý do nên để ý cảnh báo mờ của IntelliJ.

2.4 Class đã có constructor 1 tham số, nên constructor rỗng mặc định không còn tồn tại. Sửa bằng cách thêm:

public TestSuite() {
    this("Suite chưa đặt tên");
}

2.5

public class TestConfig {
    String browser;
    String baseUrl;
    int timeout;

    public TestConfig(String browser, String baseUrl, int timeout) {
        this.browser = browser;
        this.baseUrl = baseUrl;
        this.timeout = timeout;
    }

    public TestConfig() {
        this("chrome", "http://localhost:3000", 10);
    }
}

Bài 3 — Encapsulation

3.1 Vấn đề

TestCase tc = new TestCase("TC001", "PASS", 12);
tc.thoiGianGiay = -50;        // vô nghĩa, nhưng Java cho phép
tc.ketQua = "abcxyz";         // giá trị không hợp lệ

Field để "mở" thì bất kỳ đoạn code nào cũng sửa được thành bất kỳ giá trị nào. Với class nhỏ tự dùng thì không sao; với framework nhiều người dùng thì đó là nguồn lỗi.

3.2 private + getter/setter

public class TestCase {
    private String id;
    private String ketQua;
    private int thoiGianGiay;

    public TestCase(String id, String ketQua, int thoiGianGiay) {
        this.id = id;
        this.ketQua = ketQua;
        setThoiGianGiay(thoiGianGiay);
    }

    public String getId() {
        return id;
    }

    public int getThoiGianGiay() {
        return thoiGianGiay;
    }

    public void setThoiGianGiay(int thoiGianGiay) {
        if (thoiGianGiay < 0) {
            throw new IllegalArgumentException("Thời gian không thể âm: " + thoiGianGiay);
        }
        this.thoiGianGiay = thoiGianGiay;
    }
}

private = chỉ code bên trong class này truy cập được. Bên ngoài phải đi qua method — và method có thể kiểm tra.

tc.thoiGianGiay = -50;        // LỖI biên dịch — field là private
tc.setThoiGianGiay(-50);      // Ném IllegalArgumentException ngay lúc chạy

Quy ước tên: getTenField() / setTenField(). Với boolean thường là isDaFix() thay vì getDaFix().

throw sẽ học kỹ ở Giai đoạn 3. Giờ chỉ cần biết nó dừng chương trình với một thông báo lỗi rõ ràng — tốt hơn là để dữ liệu sai đi tiếp và gây lỗi ở chỗ khác.

3.3 Không phải field nào cũng cần getter/setter

Đây là chỗ nhiều người làm sai: sinh getter/setter cho tất cả field bằng Alt+Insert rồi coi như xong. Nhưng nếu mọi field đều có setter công khai thì private chẳng bảo vệ được gì — chỉ thêm việc gõ.

Nguyên tắc: chỉ mở ra những gì bên ngoài cần.

public class LoginPage {
    private WebDriver driver;      // KHÔNG có getter — bên ngoài không cần lấy driver ra
    private By emailInput = By.name("email");   // không có getter — locator là chi tiết nội bộ

    public void enterEmail(String email) { ... }   // đây là cái bên ngoài cần
}

Class không phơi ra locator hay driver, chỉ phơi ra hành động. Test đọc như tiếng người: loginPage.enterEmail(...), không phải driver.findElement(By.name("email")).sendKeys(...). Đó chính là điểm chính của Page Object Model, và nó là encapsulation áp dụng vào automation.

3.4 Field nên là private, mặc định

Thói quen tốt: viết private cho mọi field, chỉ mở lên khi có lý do cụ thể. Ở Bài 4 sẽ gặp protected — mức trung gian dành cho class con.

Bài tập 3

3.1 Viết lại class Bug với tất cả field private, có getter cho tất cả, nhưng setter chỉ cho severity, soNgayMo và daFix (id và tiêu đề không đổi được sau khi tạo).

3.2 Thêm kiểm tra trong setSeverity: chỉ nhận "Critical", "Major", "Minor". Giá trị khác thì ném IllegalArgumentException.

3.3 Class TestSuite — viết method void themKetQua(boolean pass) tăng soCase lên 1, và tăng soPass nếu pass. Không cho bên ngoài set trực tiếp soCase/soPass.

Câu hỏi kèm theo: cách này an toàn hơn setter thông thường ở điểm gì?

3.4 Đoạn này chạy được không? Giải thích.

public class TestCase {
    private String id;
    public boolean cungId(TestCase khac) {
        return this.id.equals(khac.id);
    }
}

3.5 Thiết kế class LoginPage (chưa cần code Selenium thật, dùng System.out.println giả lập). Field private: driver (dùng String giả lập), emailLocator, passwordLocator, loginButtonLocator. Method public: enterEmail, enterPassword, clickLogin, isErrorShown. Không có getter nào.

Đáp án bài 3

3.1

public class Bug {
    private String id;
    private String tieuDe;
    private String severity;
    private int soNgayMo;
    private boolean daFix;

    public Bug(String id, String tieuDe, String severity, int soNgayMo, boolean daFix) {
        this.id = id;
        this.tieuDe = tieuDe;
        setSeverity(severity);
        this.soNgayMo = soNgayMo;
        this.daFix = daFix;
    }

    public String getId() { return id; }
    public String getTieuDe() { return tieuDe; }
    public String getSeverity() { return severity; }
    public int getSoNgayMo() { return soNgayMo; }
    public boolean isDaFix() { return daFix; }

    public void setSeverity(String severity) { ... }
    public void setSoNgayMo(int soNgayMo) { this.soNgayMo = soNgayMo; }
    public void setDaFix(boolean daFix) { this.daFix = daFix; }
}

Field chỉ có getter mà không có setter gọi là read-only sau khi khởi tạo — cách phổ biến và tốt để biểu đạt "cái này không được đổi".

3.2

public void setSeverity(String severity) {
    if (!"Critical".equals(severity) && !"Major".equals(severity) && !"Minor".equals(severity)) {
        throw new IllegalArgumentException("Severity không hợp lệ: " + severity);
    }
    this.severity = severity;
}

Đây là chỗ enum sẽ tốt hơn hẳn — Java có enum cho tập giá trị cố định. Ghi lại làm việc cần tra thêm; nó không khó và rất hữu ích cho Status, Severity, Browser.

3.3

private int soCase;
private int soPass;

public void themKetQua(boolean pass) {
    soCase++;
    if (pass) soPass++;
}

An toàn hơn vì nó giữ được tính nhất quán giữa hai field. Với setter riêng lẻ, code bên ngoài có thể set soPass = 100 trong khi soCase = 5 — trạng thái vô nghĩa mà object không cách nào ngăn. Method mô tả hành động thật thì không tạo ra được trạng thái đó.

Đây là ý quan trọng nhất của encapsulation, và nó thường bị bỏ qua: mục đích không phải là che field, mà là đảm bảo object luôn ở trạng thái hợp lệ.

3.4 Chạy được. private giới hạn theo class, không theo object — code trong class TestCase truy cập được field private của mọi object TestCase, kể cả object khác. Nhiều người tưởng private là "chỉ object này", nhưng không phải.

3.5

public class LoginPage {
    private String driver;
    private String emailLocator = "[name='email']";
    private String passwordLocator = "[data-testid='password-input']";
    private String loginButtonLocator = ".btn-primary";

    public LoginPage(String driver) {
        this.driver = driver;
    }

    public void enterEmail(String email) {
        System.out.println("Gõ '" + email + "' vào " + emailLocator);
    }
    public void enterPassword(String password) {
        System.out.println("Gõ mật khẩu vào " + passwordLocator);
    }
    public void clickLogin() {
        System.out.println("Click " + loginButtonLocator);
    }
    public boolean isErrorShown() {
        return false;
    }
}

Các selector chính là những gì Linh đã viết ở module HTML/CSS. Class này sẽ thành Page Object thật ở Bài 9 — chỉ cần đổi String driver thành WebDriver driver và thay println bằng findElement.


Bài 4 — Inheritance

4.1 Vấn đề

Giả sử có ba Page Object:

public class LoginPage {
    private WebDriver driver;
    public LoginPage(WebDriver driver) { this.driver = driver; }
    public void click(By locator) { ... }
    public void type(By locator, String text) { ... }
    public String getText(By locator) { ... }
    // + method riêng của login
}

public class SearchPage {
    private WebDriver driver;
    public SearchPage(WebDriver driver) { this.driver = driver; }
    public void click(By locator) { ... }        // LẶP
    public void type(By locator, String text) { ... }   // LẶP
    public String getText(By locator) { ... }    // LẶP
}

click, type, getText giống nhau ở mọi page. Với 24 page thì đó là 24 bản copy — sửa một chỗ phải sửa 24 lần.

4.2 extends

public class BasePage {
    protected WebDriver driver;

    public BasePage(WebDriver driver) {
        this.driver = driver;
    }

    public void click(By locator) { ... }
    public void type(By locator, String text) { ... }
    public String getText(By locator) { ... }
}
public class LoginPage extends BasePage {

    private By emailInput = By.name("email");

    public LoginPage(WebDriver driver) {
        super(driver);
    }

    public void enterEmail(String email) {
        type(emailInput, email);      // dùng method của BasePage, không cần viết lại
    }
}

extends BasePage nghĩa là: LoginPage có tất cả field và method của BasePage, cộng thêm của riêng nó.

Đây là câu trả lời cho câu hỏi số 1 — extends BaseTest nghĩa là class test đó thừa hưởng toàn bộ phần setup/teardown driver đã viết một lần ở BaseTest.

Thuật ngữ: BasePage là class cha (superclass, parent), LoginPage là class con (subclass, child).

4.3 super

public LoginPage(WebDriver driver) {
    super(driver);        // gọi constructor của BasePage
}

Constructor không được thừa hưởng. Class con phải có constructor riêng, và trong đó gọi super(...) để phần của class cha được khởi tạo.

super(...) phải là câu lệnh đầu tiên trong constructor. Nếu không viết, Java tự chèn super() không tham số — và nếu class cha không có constructor rỗng thì lỗi biên dịch. Đây là lỗi rất hay gặp khi mới viết BasePage.

super cũng dùng để gọi method của cha:

super.click(locator);

4.4 protected

Modifier Truy cập được từ
private chỉ trong cùng class
(không viết gì) cùng package
protected cùng package và class con
public mọi nơi

Trong BasePage, driver phải là protected chứ không private — vì class con cần dùng nó. Đây là lý do tồn tại của protected, và trong framework automation nó xuất hiện gần như chỉ ở đúng chỗ này.

4.5 Override — ghi đè method

Class con có thể viết lại method của cha:

public class BasePage {
    public void waitForLoad() {
        System.out.println("Chờ document.readyState");
    }
}

public class DashboardPage extends BasePage {
    @Override
    public void waitForLoad() {
        System.out.println("Chờ readyState VÀ chờ spinner biến mất");
    }
}
DashboardPage p = new DashboardPage(driver);
p.waitForLoad();     // chạy bản của DashboardPage

@Override là annotation — không bắt buộc, nhưng luôn nên viết. Tác dụng: nếu Linh gõ sai tên method hoặc sai tham số, compiler báo lỗi ngay thay vì âm thầm tạo ra một method mới hoàn toàn.

Muốn thêm vào chứ không thay thế hành vi của cha:

@Override
public void waitForLoad() {
    super.waitForLoad();          // làm phần của cha trước
    System.out.println("Rồi chờ spinner");
}

4.6 Override vs Overload — phân biệt

Hai từ gần giống nhau nhưng khác hẳn. Đây là câu hỏi phỏng vấn kinh điển:

Override Overload
Ở đâu class con viết lại của class cha cùng một class (hoặc cả cha con)
Chữ ký giống hệt tên và tham số khác tham số
Mục đích đổi hành vi nhiều cách gọi tiện lợi
Quyết định lúc nào lúc chạy, theo object thật lúc biên dịch, theo tham số
public void click(By locator) { }                    // gốc
public void click(By locator, int timeout) { }        // OVERLOAD — khác tham số

4.7 Giới hạn và cạm bẫy

Java chỉ cho extends MỘT class. Không có đa kế thừa. Muốn "nhiều nguồn" thì dùng interface (Bài 6).

Đừng lồng quá sâu. BasePage → AbstractFormPage → LoginFormPage → AdminLoginPage — mỗi tầng làm code khó lần theo. Thực tế framework tốt thường chỉ 2 tầng: một BasePage và các page cụ thể.

Kế thừa là quan hệ "là một". LoginPage là một BasePage — hợp lý. Nhưng LoginPage extends ExcelReader chỉ để dùng vài method đọc Excel là sai — page không là excel reader. Trường hợp đó dùng composition: giữ một ExcelReader làm field.

Quy tắc thực dụng: cần dùng chức năng → field. Cần là một loại → extends.

Bài tập 4

4.1 Viết BasePage với field protected String driver (giả lập) và ba method click(String locator), type(String locator, String text), getText(String locator) — dùng println. Rồi viết LoginPage extends BasePage và SearchPage extends BasePage, mỗi cái có method riêng. Chạy thử.

4.2 Thêm vào BasePage method void waitForLoad(). Override nó trong SearchPage để in thêm một dòng, có gọi super.

4.3 Tại sao đoạn này lỗi biên dịch, sửa thế nào?

public class BasePage {
    protected String driver;
    public BasePage(String driver) { this.driver = driver; }
}
public class LoginPage extends BasePage {
    public LoginPage(String driver) {
        this.driver = driver;
    }
}

4.4 Đoạn này lỗi ở đâu?

public class BasePage {
    private String driver;
    public BasePage(String driver) { this.driver = driver; }
}
public class LoginPage extends BasePage {
    public LoginPage(String driver) { super(driver); }
    public void enterEmail(String email) {
        System.out.println(driver + " gõ " + email);
    }
}

4.5 Cái nào là override, cái nào là overload?

public class BasePage {
    public void click(By locator) { }
}
public class LoginPage extends BasePage {
    public void click(By locator) { }
    public void click(By locator, int timeout) { }
    public void click(String cssSelector) { }
}

4.6 Với mỗi cặp, chọn extends hay field (composition), giải thích:

  • AdminDashboardPage và DashboardPage
  • LoginPage và ConfigReader
  • ChromeTest và BaseTest
  • BugReport và ExcelWriter

Đáp án bài 4

4.1

public class BasePage {
    protected String driver;
    public BasePage(String driver) { this.driver = driver; }
    public void click(String locator) { System.out.println("Click " + locator); }
    public void type(String locator, String text) { System.out.println("Gõ '" + text + "' vào " + locator); }
    public String getText(String locator) { return "text giả lập từ " + locator; }
}

public class LoginPage extends BasePage {
    private String emailInput = "[name='email']";
    private String loginBtn = ".btn-primary";

    public LoginPage(String driver) { super(driver); }

    public void enterEmail(String email) { type(emailInput, email); }
    public void clickLogin() { click(loginBtn); }
}

Chú ý LoginPage không hề khai báo type hay click mà vẫn gọi được — đó là inheritance đang làm việc.

4.2

// BasePage
public void waitForLoad() { System.out.println("Chờ trang load"); }

// SearchPage
@Override
public void waitForLoad() {
    super.waitForLoad();
    System.out.println("Chờ kết quả tìm kiếm xuất hiện");
}

4.3 BasePage chỉ có constructor 1 tham số, nên Java không tự chèn được super() rỗng. Sửa:

public LoginPage(String driver) {
    super(driver);
}

Gán this.driver = driver cũng "hoạt động" về mặt kết quả nhưng vẫn lỗi biên dịch, vì constructor của cha bắt buộc phải được gọi trước.

4.4 driver là private trong BasePage nên LoginPage không thấy. Sửa: đổi thành protected. (Hoặc thêm getDriver() public/protected — nhưng với driver trong framework thì protected là cách quy ước.)

4.5

  • click(By locator) — override (chữ ký giống hệt của cha). Nên thêm @Override.
  • click(By locator, int timeout) — overload (thêm tham số)
  • click(String cssSelector) — overload (khác kiểu tham số)

4.6

  • AdminDashboardPage extends DashboardPage — admin dashboard là một dashboard có thêm chức năng. Hợp lý, nhưng cân nhắc: nếu khác nhau nhiều thì cả hai cùng extends BasePage sẽ sạch hơn.
  • LoginPage giữ ConfigReader làm field. Page không là config reader, nó chỉ dùng.
  • ChromeTest extends BaseTest — đúng quan hệ "là một test".
  • BugReport giữ ExcelWriter làm field — hoặc tốt hơn nữa, để một class riêng lo việc ghi file, BugReport chỉ chứa dữ liệu. Một class một trách nhiệm.

Bài 5 — Polymorphism

Bài trừu tượng nhất của giai đoạn. Nhưng nó giải thích chính xác dòng code Linh đã gõ nhiều lần mà chưa biết tại sao được phép.

5.1 Biến kiểu cha giữ object kiểu con

BasePage page = new LoginPage(driver);

Bên trái là BasePage, bên phải là LoginPage. Hợp lệ — vì LoginPage là một BasePage.

Và đây là dòng Linh đã gõ trong project Appium:

WebDriver driver = new ChromeDriver();
AppiumDriver driver = new AndroidDriver(...);

ChromeDriver là một loại WebDriver. Nên gán được. Đó là toàn bộ bí mật của câu hỏi số 2.

5.2 Hai kiểu, hai vai trò

Khi viết BasePage page = new LoginPage(driver); có hai kiểu cùng lúc:

  • Kiểu khai báo (BasePage) — quyết định Linh được gọi method nào
  • Kiểu thật (LoginPage) — quyết định bản nào thực sự chạy
BasePage page = new LoginPage(driver);
page.click(locator);        // OK — BasePage có click
page.enterEmail("a@b.c");   // LỖI BIÊN DỊCH — BasePage không có enterEmail

Compiler chỉ nhìn kiểu khai báo. Nó không quan tâm object thật là gì.

Nhưng với method đã override:

BasePage page = new SearchPage(driver);
page.waitForLoad();      // chạy bản của SearchPage, KHÔNG phải của BasePage

Kiểu khai báo cho phép gọi (vì BasePage có waitForLoad), còn bản chạy thực sự là của object thật. Cơ chế này gọi là dynamic dispatch — quyết định lúc chạy, không phải lúc biên dịch.

Ghi lại một câu: compiler quyết định gọi được gì, runtime quyết định chạy cái nào.

5.3 Vì sao điều này hữu ích

public static WebDriver createDriver(String browser) {
    if ("chrome".equals(browser)) return new ChromeDriver();
    if ("firefox".equals(browser)) return new FirefoxDriver();
    throw new IllegalArgumentException("Browser không hỗ trợ: " + browser);
}

Method trả về WebDriver — một kiểu duy nhất, dù object thật có thể là ba loại khác nhau. Toàn bộ code test sau đó viết theo WebDriver, không cần biết browser nào. Đổi browser bằng cách đổi một dòng config, không sửa một dòng test nào.

Đây là giá trị thật của polymorphism trong automation, và nó là nền của mọi cross-browser framework.

Tương tự với xử lý danh sách nhiều loại page:

BasePage[] pages = { new LoginPage(driver), new SearchPage(driver), new DashboardPage(driver) };
for (BasePage p : pages) {
    p.waitForLoad();      // mỗi page chạy bản riêng của nó
}

Một vòng lặp, ba hành vi khác nhau, không có if nào. Đây là lý do polymorphism được coi là cốt lõi của OOP.

5.4 instanceof và ép kiểu xuống

Muốn gọi method riêng của class con từ biến kiểu cha:

BasePage page = new LoginPage(driver);

if (page instanceof LoginPage) {
    LoginPage loginPage = (LoginPage) page;
    loginPage.enterEmail("a@b.c");
}

Java 16+ có cú pháp gọn hơn:

if (page instanceof LoginPage loginPage) {
    loginPage.enterEmail("a@b.c");
}

Ép kiểu sai thì ném ClassCastException lúc chạy — nên luôn kiểm tra instanceof trước.

Lưu ý về thiết kế: nếu code có nhiều instanceof, thường đó là dấu hiệu thiết kế chưa đúng — lẽ ra nên dùng override để mỗi class tự lo phần của nó. Trong code Page Object viết tốt, instanceof gần như không xuất hiện.

Bài tập 5

5.1 Với BasePage/LoginPage/SearchPage từ bài 4, tạo một mảng BasePage[] chứa cả hai loại, lặp và gọi waitForLoad(). Xác nhận mỗi phần tử chạy bản riêng.

5.2 Đoán dòng nào lỗi biên dịch, dòng nào chạy được:

BasePage p = new LoginPage(driver);
p.click("abc");
p.enterEmail("a@b.c");
LoginPage lp = new BasePage(driver);
LoginPage lp2 = (LoginPage) p;
lp2.enterEmail("a@b.c");

5.3 Viết static BasePage createPage(String tenPage, String driver) trả về LoginPage hoặc SearchPage tuỳ tham số. Gọi và dùng kết quả.

5.4 Đoán kết quả:

public class BasePage {
    public void waitForLoad() { System.out.println("BASE"); }
}
public class SearchPage extends BasePage {
    @Override
    public void waitForLoad() { System.out.println("SEARCH"); }
}
// ...
BasePage p = new SearchPage("d");
p.waitForLoad();

5.5 Giải thích bằng lời của mình: tại sao WebDriver driver = new ChromeDriver(); được phép, và tại sao người ta viết vậy thay vì ChromeDriver driver = new ChromeDriver();

Đáp án bài 5

5.1

BasePage[] pages = { new LoginPage("d"), new SearchPage("d") };
for (BasePage p : pages) {
    p.waitForLoad();
}

In ra hai dòng khác nhau — nếu SearchPage đã override. Cùng một dòng gọi, hai hành vi.

5.2

BasePage p = new LoginPage(driver);      // OK
p.click("abc");                          // OK — BasePage có click
p.enterEmail("a@b.c");                   // LỖI — BasePage không có enterEmail
LoginPage lp = new BasePage(driver);      // LỖI — BasePage không PHẢI LoginPage
LoginPage lp2 = (LoginPage) p;            // OK — ép xuống, object thật đúng là LoginPage
lp2.enterEmail("a@b.c");                  // OK

Dòng 4 là điểm cần hiểu: quan hệ chỉ đi một chiều. Mọi LoginPage đều là BasePage, nhưng không phải BasePage nào cũng là LoginPage.

5.3

public static BasePage createPage(String tenPage, String driver) {
    if ("login".equals(tenPage)) return new LoginPage(driver);
    if ("search".equals(tenPage)) return new SearchPage(driver);
    throw new IllegalArgumentException("Không có page: " + tenPage);
}

Cùng cấu trúc với createDriver ở 5.3 — và đó là Factory pattern, một trong những pattern gặp nhiều nhất trong framework automation.

5.4 In SEARCH. Kiểu khai báo là BasePage nhưng object thật là SearchPage, và method đã được override → bản của con chạy. Nếu Linh đoán BASE thì đây đúng là bài quan trọng nhất cần đọc lại.

5.5 Được phép vì ChromeDriver là một loại WebDriver (nó implement interface đó — Bài 6). Người ta viết vậy để phần code còn lại chỉ phụ thuộc vào WebDriver, không phụ thuộc vào browser cụ thể. Đổi sang Firefox chỉ sửa một dòng khởi tạo; nếu khai báo ChromeDriver driver thì mọi chỗ nhận driver làm tham số đều bị khoá vào Chrome.

Nguyên tắc chung: khai báo theo kiểu trừu tượng nhất mà vẫn đủ dùng.


Bài 6 — Abstract class và interface

6.1 Abstract class

Đôi khi class cha không nên được tạo object trực tiếp — BasePage một mình chẳng đại diện cho màn hình nào.

public abstract class BasePage {
    protected WebDriver driver;

    public BasePage(WebDriver driver) { this.driver = driver; }

    public void click(By locator) { ... }        // có sẵn thân method

    public abstract boolean isLoaded();          // KHÔNG có thân — con BẮT BUỘC viết
}
BasePage p = new BasePage(driver);    // LỖI — không tạo object từ abstract class

Class con buộc phải cài đặt method abstract:

public class LoginPage extends BasePage {
    public LoginPage(WebDriver driver) { super(driver); }

    @Override
    public boolean isLoaded() {
        return driver.findElements(By.className("login-form")).size() > 0;
    }
}

Giá trị thật: abstract biến "mỗi page nên có cách kiểm tra đã load xong" từ một quy ước bằng miệng thành một ràng buộc compiler kiểm tra được. Ai viết page mới mà quên isLoaded() thì không build được.

Abstract class vẫn có constructor, field, và method thường — nó chỉ không được tạo object trực tiếp.

6.2 Interface

Interface là một bản hợp đồng thuần: chỉ nói có những method gì, không nói làm thế nào.

public interface Reportable {
    String toReportRow();
    String getStatus();
}
public class Bug implements Reportable {
    @Override
    public String toReportRow() { return id + "," + tieuDe + "," + severity; }

    @Override
    public String getStatus() { return daFix ? "Fixed" : "Open"; }
}

public class TestCase implements Reportable {
    @Override
    public String toReportRow() { return id + "," + tenChucNang + "," + ketQua; }

    @Override
    public String getStatus() { return ketQua; }
}

Giờ viết được một method xử lý cả hai:

public static void xuatBaoCao(Reportable[] items) {
    for (Reportable r : items) {
        System.out.println(r.toReportRow() + " | " + r.getStatus());
    }
}

Bug và TestCase không có quan hệ cha con gì với nhau, nhưng cả hai đều "có thể xuất báo cáo" — và interface diễn đạt đúng điều đó.

Chi tiết cú pháp:

  • Method trong interface tự động là public abstract, không cần viết
  • Field tự động là public static final — nên interface không dùng để chứa dữ liệu
  • Java 8+ cho phép default method (có thân, class implement khỏi phải viết) và static method

6.3 Một class implement nhiều interface

public class Bug implements Reportable, Comparable<Bug> {

Đây là điểm khác biệt lớn nhất so với extends: extends chỉ được một, implements được nhiều. Và làm được cả hai cùng lúc:

public class LoginPage extends BasePage implements Loadable {

6.4 WebDriver là interface — nối lại tất cả

WebDriver driver = new ChromeDriver();

WebDriver là một interface trong Selenium. Nó khai báo findElement, get, quit, getTitle... mà không cài đặt gì. ChromeDriver, FirefoxDriver, EdgeDriver mỗi cái cài đặt theo cách riêng để nói chuyện với browser tương ứng.

Giá trị: code test của Linh chỉ phụ thuộc vào hợp đồng WebDriver. Selenium thêm browser mới cũng không ảnh hưởng gì, miễn nó implement đúng interface đó.

Cùng nguyên lý, By là abstract class với các lớp con By.ById, By.ByCssSelector, By.ByXPath — nên By.cssSelector(...) và By.xpath(...) trả về những object khác nhau mà findElement nhận hết.

6.5 Chọn abstract class hay interface

Câu hỏi Chọn
Cần chia sẻ code dùng chung? abstract class
Cần chia sẻ field (như driver)? abstract class
Chỉ cần định nghĩa hợp đồng? interface
Cần nhiều nguồn cho một class? interface
Quan hệ "là một" rõ ràng? abstract class
Quan hệ "có khả năng ..."? interface

Trong framework automation thực tế: BasePage/BaseTest là abstract class (vì có driver và code dùng chung), còn interface dùng cho các khả năng phụ như Reportable, Loadable, Retryable.

Bài tập 6

6.1 Đổi BasePage thành abstract, thêm abstract boolean isLoaded(). Cài đặt trong LoginPage và SearchPage. Thử new BasePage(...) để thấy lỗi biên dịch.

6.2 Viết interface Reportable với String toReportRow(). Cho Bug và TestCase implement. Viết method static nhận Reportable[] và in tất cả.

6.3 Tại sao đoạn này lỗi?

public abstract class BasePage {
    public abstract boolean isLoaded();
}
public class LoginPage extends BasePage {
    public LoginPage() { }
}

6.4 Đoạn này lỗi ở đâu?

public interface Loadable {
    boolean isLoaded();
    private WebDriver driver;
}

6.5 Với mỗi tình huống, chọn abstract class hay interface:

  • Mọi page cần driver và các method click/type dùng chung
  • Một số page có thể "refresh được", số khác không
  • Mọi test cần setup/teardown giống nhau
  • Cả Bug và TestCase đều cần xuất được ra CSV
  • Mọi page phải có cách kiểm tra đã load xong, nhưng cách kiểm tra khác nhau

6.6 Câu hỏi tổng hợp: giải thích bằng lời của mình, tại sao Selenium làm WebDriver thành interface thay vì một class thường. Nếu nó là class thường thì điều gì sẽ khó hơn?

Đáp án bài 6

6.1

public abstract class BasePage {
    protected String driver;
    public BasePage(String driver) { this.driver = driver; }
    public void click(String locator) { System.out.println("Click " + locator); }
    public abstract boolean isLoaded();
}

public class LoginPage extends BasePage {
    public LoginPage(String driver) { super(driver); }
    @Override
    public boolean isLoaded() { return true; }
}

6.2

public interface Reportable {
    String toReportRow();
}

public class Bug implements Reportable {
    @Override
    public String toReportRow() { return id + "," + severity + "," + tieuDe; }
}

public static void xuatBaoCao(Reportable[] items) {
    for (Reportable r : items) System.out.println(r.toReportRow());
}

Điểm đáng chú ý: Reportable[] chứa lẫn Bug và TestCase được, dù hai class không hề liên quan huyết thống.

6.3 LoginPage không cài đặt isLoaded() — class con của abstract class bắt buộc phải cài đặt mọi method abstract, hoặc chính nó cũng phải khai báo abstract.

6.4 Hai lỗi: interface không có field kiểu instance (mọi field trong interface tự động là public static final, nên private WebDriver driver; vừa sai modifier vừa thiếu giá trị khởi tạo). Cần driver dùng chung thì phải là abstract class.

6.5

  • driver + method dùng chung → abstract class (cần chia sẻ field và code)
  • "refresh được" → interface (khả năng, chỉ một số có)
  • setup/teardown chung → abstract class (BaseTest)
  • xuất CSV cho hai class không liên quan → interface
  • mọi page phải có isLoaded nhưng cách khác nhau → abstract method trong abstract class (vì BasePage đã tồn tại và cần driver)

6.6 Nếu WebDriver là class thường thì Selenium phải viết sẵn phần thân cho mọi method — nhưng cách nói chuyện với Chrome khác hoàn toàn với Firefox, nên không có thân nào đúng cho cả hai. Interface cho phép định nghĩa cái gì mà không cam kết thế nào, để mỗi browser tự lo phần của mình.

Hệ quả với người dùng: viết test theo WebDriver là viết theo hợp đồng, nên test không bị khoá vào một browser. Nếu là class thường và mỗi browser là một class con, về mặt dùng thì gần giống — nhưng Selenium sẽ mất khả năng để một class implement nhiều hợp đồng (như WebDriver + JavascriptExecutor + TakesScreenshot, mà ChromeDriver làm đúng như vậy).


Bài 7 — static, final và Object

7.1 static — nhìn lại với hiểu biết mới

Giờ đã có object rồi thì static rõ hơn hẳn:

public class TestUtils {
    private static int soTestDaChay = 0;      // MỘT biến, dùng chung cho cả class

    public static void tangSoTest() {
        soTestDaChay++;
    }
}
  • Field/method không static thuộc về từng object — 10 object có 10 bản riêng
  • Field/method static thuộc về class — chỉ có một bản duy nhất, chia sẻ toàn bộ chương trình
TestUtils.tangSoTest();       // gọi qua tên class, không cần new

Trong automation, static dùng cho:

  • Hàm tiện ích không cần trạng thái: ScreenshotUtils.capture(driver, name)
  • Hằng số cấu hình: public static final int DEFAULT_TIMEOUT = 10;
  • Đếm/tổng hợp toàn cục — nhưng cẩn thận: khi chạy test song song, biến static bị nhiều thread cùng sửa và sinh lỗi rất khó tìm. Đó là lý do framework chạy song song dùng ThreadLocal<WebDriver> chứ không dùng static WebDriver. Chưa cần làm được, nhưng nhớ là có vấn đề này.

7.2 final

public static final int DEFAULT_TIMEOUT = 10;    // hằng số
public final class Utils { }                      // không cho extends
public final void quit() { }                      // không cho override

static final cùng nhau là dạng khai báo hằng số chuẩn của Java. Tên viết IN_HOA_CO_GACH.

7.3 Mọi class đều thừa hưởng Object

Class nào không extends gì thì Java tự cho extends Object. Nên mọi object đều có sẵn toString(), equals(), hashCode().

toString() — bản mặc định vô dụng:

Bug b = new Bug("#101", "Login sai");
System.out.println(b);      // Bug@1b6d3586

Override nó:

@Override
public String toString() {
    return id + " [" + severity + "] " + tieuDe;
}
System.out.println(b);      // #101 [Critical] Login sai

System.out.println(object) tự gọi toString(). Đây là override có tỉ lệ hoàn vốn cao nhất — nó làm mọi log và mọi lần debug dễ đọc hơn hẳn. Nên viết cho mọi class chứa dữ liệu.

equals() — bản mặc định so sánh địa chỉ, y như ==:

Bug b1 = new Bug("#101", "Login sai");
Bug b2 = new Bug("#101", "Login sai");
b1.equals(b2);      // false — hai object khác nhau

Đây chính là lời giải cho câu hỏi còn nợ ở Giai đoạn 1 Bài 7.1: array .equals() ra false vì array không override equals, còn String thì có override. Không có gì thần kỳ — chỉ là class nào chịu viết thì có.

Override:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Bug khac = (Bug) o;
    return id.equals(khac.id);
}

@Override
public int hashCode() {
    return id.hashCode();
}

Quy tắc bắt buộc: override equals thì phải override hashCode, và hai object bằng nhau phải có cùng hashCode. Vi phạm sẽ gây lỗi khi dùng HashMap/HashSet (Giai đoạn 3) — object bỏ vào rồi tìm không thấy.

Trong IntelliJ: Alt+Insert → equals() and hashCode() sinh ra đúng chuẩn. Nhưng đọc hiểu đoạn nó sinh ra, đừng chỉ bấm.

Bài tập 7

7.1 Thêm toString() cho Bug, TestCase, TestSuite. In object trực tiếp bằng println.

7.2 Thêm equals()/hashCode() cho Bug, coi hai bug bằng nhau nếu cùng id. Kiểm tra bằng hai object có cùng id nhưng khác tiêu đề.

7.3 Viết class ScreenshotUtils chỉ có method static. Câu hỏi kèm: có nên cho nó constructor không, và tại sao?

7.4 Đoán kết quả:

public class Counter {
    static int dem = 0;
    int demRieng = 0;
    public void tang() { dem++; demRieng++; }
}
// ...
Counter c1 = new Counter();
Counter c2 = new Counter();
c1.tang(); c1.tang(); c2.tang();
System.out.println(c1.dem + " " + c1.demRieng);
System.out.println(c2.dem + " " + c2.demRieng);

7.5 Giải thích: vì sao "abc".equals("abc") ra true mà new int[]{1}.equals(new int[]{1}) ra false?

Đáp án bài 7

7.1

@Override
public String toString() {
    return id + " [" + severity + "] " + tieuDe + (daFix ? " (đã fix)" : "");
}

7.2

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    return id.equals(((Bug) o).id);
}
@Override
public int hashCode() { return id.hashCode(); }

Kiểm tra: hai Bug cùng id #101 khác tiêu đề → equals ra true. Đó là lựa chọn thiết kế: id là danh tính, phần còn lại là dữ liệu có thể đổi.

7.3

public final class ScreenshotUtils {
    private ScreenshotUtils() { }        // constructor private — chặn tạo object

    public static void capture(String driver, String tenFile) {
        System.out.println("Chụp ảnh " + tenFile);
    }
}

Nên có constructor private. Lý do: class chỉ chứa method static thì new ScreenshotUtils() là vô nghĩa, và constructor private ngăn người khác làm điều vô nghĩa đó. final chặn extends. Đây là mẫu chuẩn cho utility class, và là chỗ hiếm hoi constructor private hữu ích.

7.4

3 2
3 1

dem là static — một bản dùng chung, tăng 3 lần tổng cộng. demRieng là của từng object — c1 tăng 2 lần, c2 tăng 1 lần. Đây là toàn bộ khác biệt static/không static, trong một ví dụ.

(Lưu ý phụ: gọi c1.dem chạy được nhưng nên viết Counter.dem — IntelliJ sẽ cảnh báo.)

7.5 String có override equals() để so sánh từng ký tự. Array không override, nên nó dùng bản mặc định của Object, tức là so sánh địa chỉ — giống hệt ==. Muốn so nội dung array phải dùng Arrays.equals().

Kết luận thực dụng: .equals() chỉ so sánh nội dung nếu class đó chịu viết nó. Với class tự viết, nếu không override thì .equals() cũng chỉ là ==.


Bài 8 — Package và tổ chức code

8.1 Package

package com.newwave.inkedinn.pages;

public class LoginPage extends BasePage { ... }

Package là thư mục, và khai báo package phải khớp đường dẫn thật của file. Dùng import để gọi class ở package khác:

import com.newwave.inkedinn.pages.LoginPage;

Class cùng package không cần import.

8.2 Cấu trúc chuẩn của một project automation

src
├── main/java/com/newwave/inkedinn/
│   ├── pages/          LoginPage, SearchPage, BasePage
│   ├── utils/          ScreenshotUtils, ConfigReader, ExcelReader
│   ├── models/         Bug, TestCase, TestData
│   └── driver/         DriverFactory
└── test/java/com/newwave/inkedinn/
    ├── base/           BaseTest
    └── tests/          LoginTest, SearchTest

Đây là cấu trúc Maven chuẩn — src/main/java cho code hỗ trợ, src/test/java cho test. Linh đã gặp cấu trúc này trong project Appium; giờ thì lý do phân chia rõ hơn: mỗi package một trách nhiệm, và pages không được biết gì về tests.

8.3 Bảng access modifier hoàn chỉnh

Cùng class Cùng package Class con Mọi nơi
private ✓
(không viết) ✓ ✓
protected ✓ ✓ ✓
public ✓ ✓ ✓ ✓

Nguyên tắc: mặc định chọn mức hẹp nhất còn dùng được. Field private, method chỉ dùng nội bộ thì private, method để test gọi thì public.

Bài tập 8

8.1 Tổ chức lại toàn bộ class đã viết vào cấu trúc package như trên. Chạy lại để chắc chắn còn hoạt động.

8.2 Mở project Appium hiện có, vẽ ra cấu trúc package của nó. So với cấu trúc trên — khác ở đâu, và Linh nghĩ vì sao?

8.3 Với mỗi thành phần, chọn modifier phù hợp nhất và giải thích:

  • field driver trong BasePage
  • method click(By) trong BasePage
  • field emailInput trong LoginPage
  • method enterEmail(String) trong LoginPage
  • method scrollToElement(By) chỉ dùng nội bộ trong BasePage
  • hằng số DEFAULT_TIMEOUT

Đáp án bài 8

8.3

  • driver → protected (class con cần dùng)
  • click(By) → public (các page và test đều gọi)
  • emailInput → private (locator là chi tiết nội bộ, không ai ngoài LoginPage cần biết)
  • enterEmail(String) → public (test gọi)
  • scrollToElement(By) → private (chỉ dùng trong BasePage) — nếu class con cũng cần thì protected
  • DEFAULT_TIMEOUT → public static final, hoặc private static final nếu chỉ dùng nội bộ

Điểm đáng chú ý: locator private là quy ước quan trọng. Test không bao giờ nên thấy locator — nếu test cần biết locator thì Page Object đã thất bại ở mục đích của nó.


Bài 9 — Page Object Model: gộp tất cả

9.1 Câu hỏi số 3: method chaining

loginPage.enterEmail("linh@test.com").enterPassword("123456").clickLogin();

Bí mật nằm ở kiểu trả về:

public LoginPage enterEmail(String email) {
    type(emailInput, email);
    return this;              // trả về CHÍNH object này
}

public LoginPage enterPassword(String password) {
    type(passwordInput, password);
    return this;
}

enterEmail(...) trả về LoginPage, nên gọi tiếp .enterPassword(...) trên kết quả được. this chính là "object hiện tại" từ Bài 2.3.

Và khi một hành động chuyển sang màn hình khác thì trả về page mới:

public DashboardPage clickLogin() {
    click(loginButton);
    return new DashboardPage(driver);
}
DashboardPage dashboard = loginPage
        .enterEmail("linh@test.com")
        .enterPassword("123456")
        .clickLogin();

Test đọc gần như tiếng Anh, và kiểu trả về mô tả luồng chuyển màn hình — compiler kiểm tra giúp luôn. Nếu clickLogin() thực tế dẫn sang dashboard mà test gán vào LoginPage thì lỗi biên dịch ngay.

9.2 Bản hoàn chỉnh

BasePage:

public abstract class BasePage {
    protected WebDriver driver;

    public BasePage(WebDriver driver) {
        this.driver = driver;
    }

    protected void click(By locator) { driver.findElement(locator).click(); }

    protected void type(By locator, String text) {
        WebElement e = driver.findElement(locator);
        e.clear();
        e.sendKeys(text);
    }

    protected String getText(By locator) { return driver.findElement(locator).getText().trim(); }

    protected boolean isDisplayed(By locator) {
        return !driver.findElements(locator).isEmpty();
    }

    public abstract boolean isLoaded();
}

LoginPage:

public class LoginPage extends BasePage {

    private final By emailInput = By.cssSelector("[name='email']");
    private final By passwordInput = By.cssSelector("[data-testid='password-input']");
    private final By loginButton = By.cssSelector(".btn-primary");
    private final By errorMessage = By.cssSelector(".error-message");

    public LoginPage(WebDriver driver) {
        super(driver);
    }

    @Override
    public boolean isLoaded() {
        return isDisplayed(emailInput);
    }

    public LoginPage enterEmail(String email) {
        type(emailInput, email);
        return this;
    }

    public LoginPage enterPassword(String password) {
        type(passwordInput, password);
        return this;
    }

    public DashboardPage clickLogin() {
        click(loginButton);
        return new DashboardPage(driver);
    }

    public LoginPage clickLoginExpectingFailure() {
        click(loginButton);
        return this;
    }

    public String getErrorMessage() {
        return getText(errorMessage);
    }
}

Test:

public class LoginTest extends BaseTest {

    @Test
    public void loginThanhCong() {
        DashboardPage dashboard = new LoginPage(driver)
                .enterEmail("linh@test.com")
                .enterPassword("123456")
                .clickLogin();

        Assert.assertTrue(dashboard.isLoaded(), "Dashboard không load sau khi login");
    }

    @Test
    public void loginSaiMatKhau() {
        String loi = new LoginPage(driver)
                .enterEmail("linh@test.com")
                .enterPassword("sai-mat-khau")
                .clickLoginExpectingFailure()
                .getErrorMessage();

        Assert.assertEquals(loi, "Email hoặc mật khẩu không đúng");
    }
}

Điểm chốt: không có một dòng driver.findElement nào trong test. Locator đổi thì sửa đúng một chỗ trong LoginPage, không ảnh hưởng test nào.

9.3 Ba câu hỏi đầu bài, giờ trả lời được

  1. extends BaseTest — LoginTest thừa hưởng phần khởi tạo/đóng driver đã viết một lần trong BaseTest, nên mỗi test class không phải viết lại (Bài 4).
  2. WebDriver driver = new ChromeDriver() — WebDriver là interface, ChromeDriver implement nó; khai báo theo kiểu trừu tượng để code không bị khoá vào một browser (Bài 5 + 6).
  3. .enterEmail(...).enterPassword(...) — mỗi method trả về this, nên gọi tiếp được trên kết quả (Bài 2 + 9.1).

Bài tập 9

9.1 Dùng lại qa-practice-page.html và các selector đã viết ở module HTML/CSS. Viết đầy đủ (giả lập bằng println, chưa cần Selenium thật): BasePage abstract, LoginPage, CaseTablePage, BugListPage. Mỗi page có isLoaded(), locator private final, và các method hành động trả về this hoặc page tiếp theo.

9.2 Viết một class LoginTest giả lập gọi các page theo kiểu chaining. Kiểm tra: có dòng nào trong test biết về locator không? Nếu có, sửa lại.

9.3 Thêm vào CaseTablePage method String getStatusOf(String caseId) — dùng locator động, ghép caseId vào XPath. Đây là mẫu rất hay dùng:

private By statusCell(String caseId) {
    return By.xpath("//tr[td[normalize-space()='" + caseId + "']]/td[contains(@class,'status')]");
}

Câu hỏi kèm theo: tại sao đây là method trả về By chứ không phải một field By?

9.4 Vẽ trên giấy sơ đồ class của những gì vừa viết: cái gì extends cái gì, cái gì implement cái gì, field nào ở đâu.

9.5 Mở project Appium hiện có. Với từng file, trả lời:

  • Class này extends gì, implement gì?
  • Method nào là override, method nào là overload?
  • Field nào nên là private mà đang public?
  • Có method nào trả về this để chaining không?
  • Có chỗ nào locator bị lộ ra ngoài test không?

Danh sách những chỗ chưa trả lời được chính là phần cần đọc lại.

Đáp án bài 9 (chỉ 9.3)

9.3 Vì locator phụ thuộc tham số caseId — mỗi lần gọi cho ra một By khác nhau, nên nó không thể là một giá trị cố định gán lúc khởi tạo object. Field By chỉ dùng cho locator tĩnh.

Đây gọi là dynamic locator, và nó xuất hiện ở mọi bảng dữ liệu. Lưu ý về an toàn: ghép chuỗi vào XPath sẽ vỡ nếu caseId chứa dấu nháy đơn — với dữ liệu test do mình kiểm soát thì không sao, nhưng biết là có giới hạn đó.

Các bài còn lại không có đáp án — chúng là bài kiểm tra. Nếu làm được 9.1 và 9.2 mà không mở lại tài liệu, Giai đoạn 2 xong.


Xong giai đoạn 2 khi nào

Ba dấu hiệu:

  1. Viết được một Page Object mới từ đầu, không copy mẫu, trong khoảng 15 phút.
  2. Đọc một file trong project Appium và giải thích được mọi từ khoá: extends, implements, super, this, @Override, abstract, static, protected.
  3. Giải thích được cho người khác vì sao WebDriver driver = new ChromeDriver() hợp lệ.

Khi đó Giai đoạn 3 (Collections + Exception) sẽ dễ hơn hẳn — nó chủ yếu là học các class có sẵn của Java, không thêm khái niệm trừu tượng nào mới.