Cơ sở dữ liệu cho tester: bảng, khóa và bộ dữ liệu mẫu
Xem thửThứ Ba, 9:15. Lan là tester của hệ thống quản lý bán hàng Tạp Hóa Cô Tấm. Trên màn hình Danh sách đơn hàng, khách Trần Thị Bình có 4 đơn. Cô Tấm đứng quầy nói chắc nịch: "Chị Bình hôm qua mới mua ba lần thôi". Hai đơn cuối cùng cùng ngày 06/04, cùng số tiền 40.000 đồng. Lan đã biết đọc phản hồi API ở chương 7, nhưng cả màn hình lẫn API đều chỉ cho thấy phần dữ liệu mà ứng dụng chọn hiển thị.
Trước khi đọc tiếp, hãy viết ra giấy: để biết đơn thứ tư là một lần mua thật hay do hệ thống ghi trùng, Lan cần nhìn vào đâu, và cần so sánh những thông tin nào của hai đơn đó?
Sau bài này, bạn có thể
- Chỉ ra trong một bảng đâu là dòng, cột, khóa chính, khóa ngoại và ô
NULL. - Kể ba việc tester dùng SQL để làm trong dự án thật.
- Tạo bộ dữ liệu mẫu của Tạp Hóa Cô Tấm bằng DB Browser for SQLite và xác nhận có đúng 6, 8, 12 và 15 dòng.
- Nêu bốn chỗ cú pháp khác nhau giữa SQLite, MySQL và PostgreSQL mà bạn sẽ gặp khi đổi công cụ.
Cần biết trước: chương 7 (đọc phản hồi API, đối chiếu dữ liệu giữa màn hình và nguồn). Không cần biết lập trình.
Vì sao tester cần SQL
Cơ sở dữ liệu (database, viết tắt DB) là nơi ứng dụng cất mọi thứ cần nhớ lâu: khách hàng, sản phẩm, đơn hàng. Màn hình chỉ là một bản vẽ lại từ dữ liệu đó, và bản vẽ có thể sai: cắt bớt, làm tròn, gộp, hoặc cache cũ. SQL (Structured Query Language, ngôn ngữ truy vấn có cấu trúc) là cách duy nhất ổn định để hỏi thẳng cơ sở dữ liệu "bạn đang thực sự chứa gì". Với tester, có ba việc lặp đi lặp lại:
| Việc | Ví dụ ở Tạp Hóa Cô Tấm | Học ở bài |
|---|---|---|
| Đối chiếu dữ liệu giữa màn hình và DB | Màn hình báo 4 đơn của chị Bình, DB có bao nhiêu dòng khớp? | 2, 3, 6 |
| Chuẩn bị và dọn dữ liệu thử | Tạo khách thử, đặt tồn kho một sản phẩm về 0 để thử cảnh báo hết hàng | 5 |
| Tìm nguyên nhân lỗi | Đơn có tổng tiền khác tổng các dòng chi tiết, đơn không có chi tiết | 4, 6 |
Lưu ý một điều: tester không sửa dữ liệu thật để cho khớp. SQL ở khóa này chủ yếu là để xem (câu SELECT). Bài 5 mới dạy thêm, sửa, xóa, và chỉ trên môi trường test.
Mô hình tư duy 1: bảng là một tấm lưới gồm cột và dòng
Một bảng (table) giống một tờ sổ kẻ ô. Cột (column) là tiêu đề: mỗi cột mô tả một loại thông tin và có kiểu dữ liệu cố định. Dòng (row, còn gọi record, bản ghi) là một đối tượng cụ thể: một khách hàng, một đơn hàng. Giao của dòng và cột là một ô chứa đúng một giá trị.
Bảng customers của Tạp Hóa Cô Tấm có 6 dòng, mỗi dòng một khách, và 5 cột:
| Cột | Kiểu | Ý nghĩa |
|---|---|---|
customer_id | số nguyên | số định danh của khách |
full_name | chuỗi tối đa 100 ký tự | họ tên |
email | chuỗi | email, có thể chưa có |
phone | chuỗi | số điện thoại, có thể chưa có |
created_at | ngày | ngày tạo hồ sơ |
Có ba nhóm kiểu bạn gặp gần như mọi lúc: số (INTEGER), chuỗi (VARCHAR, TEXT) và ngày (DATE). Hai quyết định thiết kế đáng để tester biết vì chúng sinh ra lỗi thật: số điện thoại là chuỗi vì số 0 đứng đầu sẽ bị mất nếu lưu dạng số, và tiền lưu bằng số nguyên đồng (75000, không phải 75000,5) để tránh sai số làm tròn.
Mô hình tư duy 2: khóa nối các bảng với nhau
Nếu nhét mọi thứ vào một bảng khổng lồ, tên và số điện thoại của chị Bình sẽ bị chép lại ở mỗi đơn, và sửa một chỗ thì quên chỗ khác. Vì vậy dữ liệu được chia thành nhiều bảng và nối bằng khóa.
- Khóa chính (primary key, PK): cột hoặc nhóm cột mà giá trị không trùng, không rỗng giữa các dòng, dùng để gọi đúng một dòng. Ví dụ
customers.customer_id. - Khóa ngoại (foreign key, FK): cột trong bảng này mang giá trị khóa chính của bảng khác. Ví dụ
orders.customer_idtrỏ tớicustomers.customer_id. Nhờ vậy đơn số 11 biết nó thuộc khách số 2 mà không cần chép lại tên khách.
Bốn bảng của bài toán nối nhau theo chuỗi sau:
| Quan hệ | Đọc là | Số lượng |
|---|---|---|
orders.customer_id trỏ tới customers.customer_id | mỗi đơn thuộc về một khách (hoặc chưa rõ khách) | một khách có nhiều đơn |
order_items.order_id trỏ tới orders.order_id | mỗi dòng chi tiết thuộc về một đơn | một đơn có nhiều dòng chi tiết |
order_items.product_id trỏ tới products.product_id | mỗi dòng chi tiết nói về một sản phẩm | một sản phẩm xuất hiện ở nhiều chi tiết |
Quan hệ kiểu "một bên nhiều bên" này (một khách, nhiều đơn) sẽ là nguồn gốc của bẫy quan trọng nhất ở bài 4.
Mô hình tư duy 3: NULL nghĩa là chưa biết, không phải rỗng và không phải 0
NULL là dấu hiệu "ô này không có giá trị". Nó khác chuỗi rỗng '' (có giá trị, chỉ là chuỗi dài 0 ký tự) và khác số 0 (có giá trị, là không). Trong bộ dữ liệu mẫu có ba chỗ NULL cố ý:
- Khách Lê Hoàng Cường chưa có email.
- Khách Phạm Thu Dung chưa có số điện thoại.
- Đơn số 8 có
customer_idlàNULL: khách vãng lai không để lại thông tin.
Với tester, NULL quan trọng vì nó làm các phép so sánh cho kết quả "không biết" chứ không phải "đúng" hay "sai". Bài 2 sẽ cho bạn thấy một truy vấn tưởng là bắt được mọi khách chưa có email nhưng lại không bắt được ai.
Bộ dữ liệu mẫu của Tạp Hóa Cô Tấm
Từ bài này đến hết chương, mọi truy vấn chạy trên cùng một cơ sở dữ liệu nhỏ: 6 khách hàng, 8 sản phẩm, 12 đơn hàng và 15 dòng chi tiết đơn. Cú pháp dưới đây là SQL chung chạy được trên SQLite, MySQL và PostgreSQL (chỗ khác nhau được nói ở mục cuối bài). Tên bảng và cột viết tiếng Anh theo thông lệ, phần giải thích viết tiếng Việt.
Lệnh tạo bốn bảng
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
full_name VARCHAR(100) NOT NULL,
email VARCHAR(100),
phone VARCHAR(20),
created_at DATE NOT NULL
);
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
product_name VARCHAR(100) NOT NULL,
category VARCHAR(30) NOT NULL,
price INTEGER NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers (customer_id),
order_date DATE NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('pending', 'paid', 'cancelled')),
payment_method VARCHAR(20) NOT NULL,
total_amount INTEGER NOT NULL
);
CREATE TABLE order_items (
order_item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders (order_id),
product_id INTEGER NOT NULL REFERENCES products (product_id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price INTEGER NOT NULL
);
Bạn không cần thuộc cú pháp này. Điều cần là đọc ra quy tắc mà cơ sở dữ liệu tự bắt buộc, vì mỗi quy tắc là một chỗ để tester thử vượt qua:
| Đoạn trong lệnh | Cơ sở dữ liệu làm gì | Vì sao tester quan tâm |
|---|---|---|
PRIMARY KEY | Từ chối dòng có mã trùng hoặc rỗng | Thử thêm hai khách cùng customer_id |
NOT NULL | Từ chối ô để trống | Thử thêm khách không có full_name |
REFERENCES customers (customer_id) | Đây là khóa ngoại: chỉ nhận mã khách có thật (khi cơ chế này đang bật) | Thử tạo đơn cho khách số 99 |
CHECK (status IN (...)) | Chỉ nhận ba trạng thái đã khai báo | Thử trạng thái refunded |
CHECK (quantity > 0) | Từ chối số lượng bằng 0 hoặc âm | Biên số lượng bằng 0 |
DEFAULT 0 | Nếu không điền stock, tự đặt 0 | Sản phẩm mới mặc định hết hàng |
customer_id trong orders không có NOT NULL | Cho phép NULL | Đơn vãng lai không có khách |
Lệnh thêm dữ liệu
Thứ tự thêm quan trọng: bảng được trỏ tới (customers, products) phải có dữ liệu trước bảng đi trỏ (orders, order_items). Một câu INSERT có thể thêm nhiều dòng, mỗi dòng nằm trong một cặp ngoặc.
INSERT INTO customers (customer_id, full_name, email, phone, created_at) VALUES
(1, 'Nguyễn Văn An', 'an.nguyen@example.com', '0901000001', '2026-01-05'),
(2, 'Trần Thị Bình', 'binh.tran@example.com', '0901000002', '2026-01-12'),
(3, 'Lê Hoàng Cường', NULL, '0901000003', '2026-02-03'),
(4, 'Phạm Thu Dung', 'dung.pham@example.com', NULL, '2026-02-20'),
(5, 'Võ Minh Em', 'em.vo@example.com', '0901000005', '2026-03-08'),
(6, 'Đặng Gia Hân', 'han.dang@example.com', '0901000006', '2026-03-15');
INSERT INTO products (product_id, product_name, category, price, stock) VALUES
(1, 'Nuoc suoi 500ml', 'drink', 5000, 120),
(2, 'Tra xanh chai', 'drink', 10000, 60),
(3, 'Nuoc ngot lon', 'drink', 12000, 0),
(4, 'Mi goi tom', 'food', 4500, 200),
(5, 'Banh quy bo', 'food', 25000, 35),
(6, 'Keo dua', 'food', 15000, 48),
(7, 'Nuoc rua chen', 'household', 32000, 15),
(8, 'Giay ve sinh', 'household', 28000, 5);
INSERT INTO orders (order_id, customer_id, order_date, status, payment_method, total_amount) VALUES
( 1, 1, '2026-04-01', 'paid', 'cash', 75000),
( 2, 2, '2026-04-01', 'paid', 'transfer', 45000),
( 3, 1, '2026-04-02', 'paid', 'cash', 60000),
( 4, 3, '2026-04-02', 'pending', 'transfer', 60000),
( 5, 4, '2026-04-03', 'cancelled', 'cash', 50000),
( 6, 2, '2026-04-03', 'paid', 'card', 90000),
( 7, 6, '2026-04-04', 'paid', 'cash', 90000),
( 8, NULL, '2026-04-04', 'paid', 'cash', 10000),
( 9, 3, '2026-04-05', 'paid', 'transfer', 67000),
(10, 4, '2026-04-05', 'paid', 'cash', 60000),
(11, 2, '2026-04-06', 'paid', 'transfer', 40000),
(12, 2, '2026-04-06', 'paid', 'transfer', 40000);
INSERT INTO order_items (order_item_id, order_id, product_id, quantity, unit_price) VALUES
( 1, 1, 1, 6, 5000),
( 2, 1, 4, 10, 4500),
( 3, 2, 2, 2, 10000),
( 4, 2, 5, 1, 25000),
( 5, 3, 7, 1, 32000),
( 6, 3, 8, 1, 28000),
( 7, 4, 3, 5, 12000),
( 8, 5, 5, 2, 25000),
( 9, 6, 1, 12, 5000),
(10, 6, 2, 3, 10000),
(11, 7, 4, 20, 4500),
(12, 8, 1, 2, 5000),
(13, 10, 8, 2, 28000),
(14, 11, 2, 4, 10000),
(15, 12, 2, 4, 10000);
Chỗ này có bốn tình huống được cài sẵn để các bài sau tự phát hiện, nên bạn đừng vội đọc kỹ từng dòng. Chỉ cần nhớ rằng bộ dữ liệu không sạch, giống một hệ thống thật: có khách chưa từng mua, có đơn không rõ khách, có đơn cần được soi kỹ, có sản phẩm hết hàng.
Thực hành: dựng cơ sở dữ liệu trong DB Browser for SQLite
Khóa này chọn DB Browser for SQLite (miễn phí, cài như một ứng dụng bình thường tại sqlitebrowser.org, có bản cho Windows, macOS và Linux). Lý do: cơ sở dữ liệu SQLite là một tệp duy nhất trên máy, không cần cài máy chủ, không cần mật khẩu. Các lệnh trong bài cũng chạy được trên MySQL Workbench, DBeaver và pgAdmin sau khi bạn đã quen.
- Mở ứng dụng, bấm New Database, đặt tên tệp
tap_hoa_co_tam.db. Nếu hộp thoại tạo bảng hiện ra, bấm Cancel. - Mở thẻ Execute SQL, dán lệnh tạo bảng rồi bấm nút chạy tất cả (phím tắt thường là Ctrl và Enter). Sau đó dán lệnh thêm dữ liệu và chạy tiếp.
- Bấm Write Changes (Ctrl và S). Bước này lưu thật vào tệp. Nếu quên, đóng ứng dụng là mất hết.
- Chạy thử câu sau để xem toàn bộ bảng khách hàng.
Tên nút có thể khác một chút giữa các phiên bản, nhưng thẻ Execute SQL và việc phải lưu thay đổi thì luôn có. Công cụ đồ họa chỉ hiện kết quả của câu lệnh cuối cùng trong ô soạn thảo, nên trong bài này mỗi khối lệnh có đúng một câu SELECT cần xem.
SELECT * FROM customers;
customer_id | full_name | email | phone | created_at
------------+----------------+-----------------------+------------+-----------
1 | Nguyễn Văn An | an.nguyen@example.com | 0901000001 | 2026-01-05
2 | Trần Thị Bình | binh.tran@example.com | 0901000002 | 2026-01-12
3 | Lê Hoàng Cường | NULL | 0901000003 | 2026-02-03
4 | Phạm Thu Dung | dung.pham@example.com | NULL | 2026-02-20
5 | Võ Minh Em | em.vo@example.com | 0901000005 | 2026-03-08
6 | Đặng Gia Hân | han.dang@example.com | 0901000006 | 2026-03-15
(6 dòng)
Dấu * nghĩa là "tất cả các cột". Kết quả trong bài được in theo kiểu dòng lệnh (chữ căn trái, NULL viết rõ); công cụ đồ họa hiện thành lưới, ô NULL thường hiện chữ NULL mờ hoặc để trống tùy phiên bản. Để chắc bạn dựng đúng, đếm số dòng của cả bốn bảng. Câu này nối bốn kết quả đếm thành một bảng bằng UNION ALL; bạn chưa cần hiểu từng phần, chỉ cần thấy bốn con số đúng.
SELECT 'customers' AS table_name, COUNT(*) AS row_count FROM customers
UNION ALL SELECT 'products', COUNT(*) FROM products
UNION ALL SELECT 'orders', COUNT(*) FROM orders
UNION ALL SELECT 'order_items', COUNT(*) FROM order_items;
table_name | row_count
------------+----------
customers | 6
products | 8
orders | 12
order_items | 15
(4 dòng)
Nếu bạn có 6, 8, 12 và 15 là xong. Sai số nào, xóa tệp .db và dựng lại từ đầu, vì chạy lệnh tạo bảng lần hai trên tệp cũ sẽ báo bảng đã tồn tại. Trước khi sang bài sau, hãy quay lại câu hỏi của Lan. Ta đã có đủ kiến thức để xem chị Bình có những đơn nào (cú pháp lọc sẽ được dạy kỹ ở bài 2):
SELECT order_id, order_date, status, total_amount FROM orders WHERE customer_id = 2;
order_id | order_date | status | total_amount
---------+------------+--------+-------------
2 | 2026-04-01 | paid | 45000
6 | 2026-04-03 | paid | 90000
11 | 2026-04-06 | paid | 40000
12 | 2026-04-06 | paid | 40000
(4 dòng)
Đơn 11 và 12 giống nhau từ ngày, trạng thái đến số tiền. Đây là bằng chứng mà màn hình không nói ra: có thể chị Bình bấm nút đặt hàng hai lần, hoặc ứng dụng ghi trùng. Chỉ nhìn một bảng thì chưa kết luận được là bug, Lan còn cần so với dữ liệu chi tiết (bài 4) và nhật ký giao dịch. Nhưng giờ Lan có một bằng chứng cụ thể để ghi vào báo cáo lỗi, thay vì "hình như đơn bị lặp".
Cơ chế: tester kiểm tra một thao tác qua cơ sở dữ liệu thế nào
Quy trình đối chiếu ở mọi bài sau đều theo cùng một nhịp: thao tác trên giao diện, rồi hỏi cơ sở dữ liệu, rồi so hai bên.
Đang tải sơ đồ…
Cách đọc hình: thời gian chảy từ trên xuống. Hai mũi tên đầu là đường của ứng dụng: màn hình lưu vào cơ sở dữ liệu. Hai mũi tên cuối là đường của tester: tự hỏi cơ sở dữ liệu một lần nữa, không qua màn hình. Chỗ so sánh ở cuối cùng là nơi bug lộ ra. Màn hình báo "đã tạo" chỉ chứng minh ứng dụng nói đã tạo, chưa chứng minh dòng dữ liệu nằm trong bảng.
Hoạt ảnh dưới đây đi theo khóa ngoại của đơn số 4 (đơn đang chờ xử lý của khách Cường) qua ba bảng. Hãy dự đoán trước khi bấm: đơn này cần mấy sản phẩm, và kho còn hàng không?
Đang tải Animation…
Hiểu lầm thường gặp
- "Cơ sở dữ liệu là một bảng tính Excel lớn." Bảng tính chỉ có một lưới, còn cơ sở dữ liệu nhiều bảng nối nhau bằng khóa và tự từ chối dữ liệu vi phạm quy tắc. Chính các quy tắc này (khóa chính,
NOT NULL,CHECK) mới là thứ tester thử vượt qua. - "
NULLgiống như số 0 hoặc chuỗi rỗng." Sai ở chỗ: số 0 và chuỗi rỗng là giá trị thật, cònNULLlà không có giá trị. Đếm, cộng và so sánh đều đối xử với chúng khác nhau, như bài 2 và bài 3 sẽ chứng minh. - "Màn hình đúng thì dữ liệu chắc chắn đúng." Sai ở chỗ: màn hình là bản vẽ lại. Lỗi có thể nằm ở bước vẽ (làm tròn, cắt chuỗi) hoặc ở bước lưu. Phải hỏi cơ sở dữ liệu thì mới tách được hai loại lỗi.
- "Chạy xong lệnh là dữ liệu đã được lưu." Nhiều công cụ giữ thay đổi trong một giao dịch chưa chốt cho đến khi bạn bấm lưu (Write Changes ở DB Browser for SQLite). Quên lưu là nguyên nhân hay gặp nhất của "sao tôi mở lại không thấy gì".
Điều kiện và giới hạn: SQL chung và chỗ phương ngữ khác nhau
Các hệ quản trị cơ sở dữ liệu (SQLite, MySQL, PostgreSQL, SQL Server) dùng chung phần lõi SELECT, WHERE, JOIN, GROUP BY. Chúng khác nhau ở các chi tiết sau, và bạn sẽ gặp từng cái trong chương này:
| Chủ đề | SQLite | MySQL | PostgreSQL |
|---|---|---|---|
| Mã tự tăng khi thêm dòng | INTEGER PRIMARY KEY tự tăng | AUTO_INCREMENT | GENERATED ALWAYS AS IDENTITY hoặc SERIAL |
| Kiểu chuỗi, ngày | Kiểu rất linh hoạt, nhận VARCHAR, DATE nhưng không ép chặt | VARCHAR(n), DATE chặt | VARCHAR(n), TEXT, DATE chặt |
| Cách kết nối | Một tệp trên máy, không có máy chủ | Máy chủ, cổng mặc định 3306, cần tên người dùng và mật khẩu | Máy chủ, cổng mặc định 5432, cần tên người dùng và mật khẩu |
| Lấy N dòng đầu | LIMIT N | LIMIT N | LIMIT N (SQL Server dùng TOP N) |
| Kiểm tra khóa ngoại | Mặc định tắt, bật bằng PRAGMA foreign_keys = ON | Luôn bật với bảng InnoDB | Luôn bật |
Khi kết nối tới MySQL hay PostgreSQL ở công ty, bạn thường nhận từ nhóm phát triển bốn thông tin: địa chỉ máy chủ, cổng, tên cơ sở dữ liệu, tên người dùng. Đừng tự đoán, và đừng bao giờ chạy lệnh ghi khi chưa chắc mình đang ở môi trường test (bài 5 sẽ nói kỹ).
Tự kiểm tra (có đáp án)
Câu 1. Trong bảng orders, cột nào là khóa ngoại và nó trỏ tới đâu? Vì sao đơn số 8 vẫn hợp lệ dù cột đó là NULL?
Đáp án: cột customer_id là khóa ngoại, trỏ tới customers.customer_id. Đơn số 8 hợp lệ vì cột này không khai báo NOT NULL, tức là cho phép đơn không gắn với khách nào (khách vãng lai). Khóa ngoại chỉ kiểm tra những giá trị có, không ép phải có giá trị.
Câu 2. Một tester lưu số điện thoại 0901000001 vào cột kiểu số rồi thấy màn hình hiện 901000001. Lỗi nằm ở đâu và nên sửa thế nào?
Đáp án: lỗi thiết kế kiểu dữ liệu: kiểu số bỏ số 0 đứng đầu vì với số học 0901 và 901 là một. Số điện thoại là mã định danh, không phải đại lượng để cộng trừ, nên lưu bằng chuỗi như cột phone của bài này.
Câu 3. Màn hình báo "Tạo đơn thành công". Việc tối thiểu nào bạn làm để chứng minh dữ liệu thật sự đã vào cơ sở dữ liệu?
Đáp án: chạy SELECT trên bảng orders theo các giá trị bạn vừa nhập (mã khách, ngày, số tiền), thấy đúng số dòng mong đợi, rồi mở bảng order_items để chắc dòng chi tiết cũng có. Chỉ thông báo trên màn hình thì chưa đủ.
Điều cần nhớ
- Cơ sở dữ liệu gồm nhiều bảng, mỗi bảng có cột (kiểu cố định) và dòng (một đối tượng).
- Khóa chính gọi đúng một dòng, khóa ngoại nối dòng này tới dòng của bảng khác. Một khách có nhiều đơn, một đơn có nhiều chi tiết.
NULLlà chưa biết, khác0và khác chuỗi rỗng.- Tester dùng SQL để đối chiếu, chuẩn bị dữ liệu thử và tìm nguyên nhân lỗi; màn hình chỉ là bản vẽ lại dữ liệu.
- Bài sau dạy câu
SELECTvớiWHERE,ORDER BY,LIMITđể lấy đúng những dòng cần xem.
Nguồn tham khảo
- Tài liệu SQLite về kiểu dữ liệu: https://www.sqlite.org/datatype3.html
- Tài liệu SQLite về khóa ngoại: https://www.sqlite.org/foreignkeys.html
- Tài liệu SQLite về lệnh
CREATE TABLE: https://www.sqlite.org/lang_createtable.html - Tài liệu PostgreSQL về ràng buộc: https://www.postgresql.org/docs/current/ddl-constraints.html
- DB Browser for SQLite: https://sqlitebrowser.org