This is real output. We deleted the WHERE from one line of the homework app, and the tests found it in a seventh of a second.
นี่คือผลลัพธ์จริง เราลบ WHERE ออกจากโค้ดแอปการบ้านหนึ่งบรรทัด แล้วเทสต์จับได้ในเวลาเศษหนึ่งส่วนเจ็ดวินาที
这是真实的输出。我们把作业程序里某一行的 WHERE 删掉,测试在七分之一秒内就发现了。
🇬🇧 English
You already built the homework app. You checked it by hand: add a job, look, mark it done, look again.
Doing that by hand is slow, and you will stop doing it. A test is that same check, written down once, and run in under a second for ever.
Tests are not about being clever. They are about being able to change your code tomorrow without being afraid.
🇹🇭 ไทย
คุณสร้างแอปการบ้านไว้แล้ว และตรวจด้วยมือ คือเพิ่มงาน ดูผล ทำเครื่องหมายว่าเสร็จ แล้วดูอีกที
การตรวจด้วยมือแบบนั้นช้า และสุดท้ายคุณจะเลิกทำ ส่วน เทสต์ คือการตรวจแบบเดียวกัน ที่เขียนไว้ครั้งเดียว แล้วรันได้ในไม่ถึงวินาที ตลอดไป
เทสต์ไม่ได้มีไว้อวดว่าเก่ง แต่มีไว้ให้คุณกล้าแก้โค้ดของตัวเองในวันพรุ่งนี้โดยไม่ต้องกลัว
🇨🇳 中文
你已经做好了作业程序,也用手检查过:加一条、看一眼、标记完成、再看一眼。
用手查很慢,而且你迟早会不查了。测试就是同样的检查,只写一次,以后每次不到一秒就跑完。
测试不是为了显得聪明,是为了让你明天改代码的时候不用害怕。
0 Get ready · เตรียมตัว · 做好准备
🇬🇧 English
One library. Put the new file next to homework.py.
The name matters. pytest finds files that start with test_ and functions that start with test_. Get the name wrong and it will politely find nothing.
🇹🇭 ไทย
ไลบรารีเดียว วางไฟล์ใหม่ไว้ข้าง ๆ homework.py
ชื่อไฟล์สำคัญ pytest จะมองหาไฟล์ที่ขึ้นต้นด้วย test_ และฟังก์ชันที่ขึ้นต้นด้วย test_ ถ้าตั้งชื่อผิด มันจะไม่เจออะไรเลยอย่างสุภาพ
🇨🇳 中文
只要一个库。把新文件放在 homework.py 旁边。
名字很重要。pytest 只找以 test_ 开头的文件和以 test_ 开头的函数。名字写错,它会很客气地什么都找不到。
pip3 install pytest
cd ~/Documents/Code/homework
vim test_homework.py
1 Your first test is three lines · เทสต์แรกของคุณยาวสามบรรทัด · 你的第一个测试只有三行
🇬🇧 English
assert means this must be true. If it is true, nothing happens. If it is false, the test fails and tells you.
That is the whole idea. There is nothing else to learn about assert.
🇹🇭 ไทย
assert แปลว่า สิ่งนี้ต้องเป็นจริง ถ้าจริงก็ไม่มีอะไรเกิดขึ้น ถ้าไม่จริง เทสต์จะล้มเหลวและบอกคุณ
นั่นคือแนวคิดทั้งหมด ไม่มีอะไรต้องเรียนเพิ่มเกี่ยวกับ assert อีกแล้ว
🇨🇳 中文
assert 的意思是这件事必须为真。为真就什么都不发生;为假,测试就失败,并且告诉你。
这就是全部。关于 assert 没有别的要学的了。
def test_maths_still_works():
assert 2 + 2 == 4
python3 -m pytest -v
test_homework.py::test_maths_still_works PASSED [100%]
1 passed in 0.01s
2 Every test needs a clean start · ทุกเทสต์ต้องเริ่มจากที่ว่าง · 每个测试都要从干净的状态开始
🇬🇧 English
Your tests must never touch your real homework.db. And each test must start empty, or the first test changes what the second one sees.
A fixture is set-up that pytest runs before a test. Write db in the test's brackets and pytest hands you a fresh database.
tmp_path is a brand new empty folder, made by pytest and thrown away after. monkeypatch points the app at it for one test only, then puts everything back.
Anything after yield runs when the test finishes, even if it failed.
🇹🇭 ไทย
เทสต์ของคุณต้องไม่ไปแตะ homework.db ตัวจริงเด็ดขาด และทุกเทสต์ต้องเริ่มจากฐานข้อมูลว่าง ไม่งั้นเทสต์แรกจะไปเปลี่ยนสิ่งที่เทสต์ที่สองเห็น
fixture คือการเตรียมของที่ pytest รันให้ก่อนเริ่มเทสต์ แค่เขียน db ในวงเล็บของเทสต์ pytest ก็จะส่งฐานข้อมูลใหม่เอี่ยมมาให้
tmp_path คือโฟลเดอร์เปล่าอันใหม่ ที่ pytest สร้างให้แล้วทิ้งไปหลังใช้ ส่วน monkeypatch จะชี้แอปไปที่นั่นเฉพาะเทสต์เดียว แล้วคืนค่าเดิมให้ทีหลัง
โค้ดที่อยู่หลัง yield จะทำงานตอนเทสต์จบ แม้ว่าเทสต์นั้นจะล้มเหลวก็ตาม
🇨🇳 中文
你的测试绝对不能碰真正的 homework.db。而且每个测试都要从空的开始,否则第一个测试会改变第二个测试看到的东西。
fixture 是 pytest 在测试之前帮你做的准备。只要在测试的括号里写 db,pytest 就会递给你一个全新的数据库。
tmp_path 是一个全新的空文件夹,pytest 建好、用完就扔。monkeypatch 只在这一个测试里把程序指过去,之后自动还原。
yield 后面的代码会在测试结束时运行 —— 就算测试失败了也会。
import pytest
import homework
@pytest.fixture
def db(tmp_path, monkeypatch):
monkeypatch.setattr(homework, "DB_PATH", tmp_path / "test.db")
conn = homework.open_db()
yield conn # the test runs here
conn.close() # this happens afterwards, always
def test_new_database_is_empty(db):
assert db.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 0
3 One test, many names · เทสต์เดียว หลายชื่อ · 一个测试,很多名字
🇬🇧 English
Remember O'Brien from the homework lesson? The apostrophe that broke the naive INSERT?
Write it down as a test and it can never come back. parametrize runs the same test once for every name in the list, and names each run after its input.
Include the awkward ones on purpose: apostrophes, quotes, Thai, Chinese, and the famous attack string. Each one is a bug that will never surprise you again.
🇹🇭 ไทย
จำ O'Brien จากบทเรียนแอปการบ้านได้ไหม เครื่องหมาย ' ที่ทำให้ INSERT แบบง่าย ๆ พังนั่นแหละ
เขียนมันเป็นเทสต์ไว้ แล้วมันจะไม่มีวันกลับมาอีก parametrize จะรันเทสต์เดิมซ้ำหนึ่งครั้งต่อชื่อหนึ่งชื่อในลิสต์ และตั้งชื่อการรันแต่ละครั้งตามข้อมูลที่ใส่เข้าไป
ใส่ตัวยาก ๆ เข้าไปด้วยตั้งใจ ทั้งเครื่องหมายวรรคตอน ภาษาไทย ภาษาจีน และสตริงโจมตีชื่อดัง แต่ละตัวคือบั๊กที่จะไม่ทำให้คุณตกใจอีกต่อไป
🇨🇳 中文
还记得作业那一课里的 O'Brien 吗?就是那个把简单 INSERT 弄崩的撇号。
把它写成测试,它就再也回不来了。parametrize 会用列表里的每个名字把同一个测试跑一遍,并用输入给每次运行命名。
故意放一些难搞的进去:撇号、引号、泰文、中文,还有那个著名的攻击字符串。每一个都是一个再也不会让你意外的 bug。
@pytest.mark.parametrize("name", [
"Ploy",
"O'Brien",
'Say "hello"',
"น้องหมี",
"张伟",
"Robert'); DROP TABLE jobs;--", # the classic attack
])
def test_any_name_survives(db, name):
homework.add(db, Args(student=name, title="Read page 40", due=None))
saved = db.execute("SELECT student FROM jobs").fetchone()[0]
assert saved == name
assert db.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 1
python3 -m pytest -v. One line per name. The last one tries to delete your table and fails to.
python3 -m pytest -v จะได้หนึ่งบรรทัดต่อหนึ่งชื่อ ชื่อสุดท้ายพยายามจะลบตารางของคุณ แต่ทำไม่สำเร็จ
python3 -m pytest -v。每个名字一行。最后那个想删掉你的表,但没得逞。
4 Break it on purpose · ทำให้พังโดยตั้งใจ · 故意把它弄坏
🇬🇧 English
This is the most important step. A test you have never seen fail is not a test. It might be checking nothing at all, and you would not know.
So break your app on purpose, three times, and watch. Put each line back afterwards.
Look at what the failures say. Not "something is wrong" — they tell you the exact rows, the exact numbers, and the exact line.
The third one is the best. Everything looks fine while the program runs. Only when the data is read back from disk does the missing commit() show up.
🇹🇭 ไทย
นี่คือขั้นที่สำคัญที่สุด เทสต์ที่คุณไม่เคยเห็นมันล้มเหลว ไม่นับว่าเป็นเทสต์ เพราะมันอาจไม่ได้ตรวจอะไรเลย และคุณจะไม่มีทางรู้
ฉะนั้นลองทำให้แอปพังโดยตั้งใจสามแบบ แล้วดูผล จากนั้นค่อยใส่โค้ดกลับคืน
ดูสิ่งที่ข้อความล้มเหลวบอก ไม่ใช่แค่ "มีอะไรผิด" แต่มันบอกแถวที่ผิด ตัวเลขที่ผิด และบรรทัดที่ผิด อย่างชัดเจน
อันที่สามดีที่สุด ทุกอย่างดูปกติตอนโปรแกรมทำงาน จะเห็นว่าลืม commit() ก็ต่อเมื่ออ่านข้อมูลกลับจากดิสก์เท่านั้น
🇨🇳 中文
这是最重要的一步。一个你从没见过它失败的测试,不算测试。它可能什么都没检查,而你根本不会知道。
所以故意把程序弄坏三次,看看结果。之后再把每一行改回来。
看看失败信息说了什么。不是"有地方不对" —— 它告诉你具体是哪几行、哪些数字、哪一行代码。
第三个最妙。程序运行的时候一切正常,只有当数据从磁盘读回来时,漏掉的 commit() 才会露馅。
| Break this in homework.py | What the test says |
|---|---|
Glue the name in with %s instead of ? | FAILED test_any_name_survives[O'Brien] |
Delete WHERE id = ? from the UPDATE | assert [(1, 1), (2, 1)] == [(1, 1), (2, 0)] |
Delete conn.commit() from add() | assert 0 == 1 |
5 Test what the person sees · ทดสอบสิ่งที่ผู้ใช้เห็น · 测试用户看到的东西
🇬🇧 English
Your report function does not return anything. It prints. So catch the printing.
capsys is another fixture. It grabs everything your program printed, so you can check it.
Check for the important words, not the exact spacing. A test that breaks when you add one space is a test you will start ignoring.
🇹🇭 ไทย
ฟังก์ชัน report ของคุณไม่ได้คืนค่าอะไร มันพิมพ์ออกมา ฉะนั้นก็ไปดักสิ่งที่มันพิมพ์
capsys คือ fixture อีกตัวหนึ่ง มันเก็บทุกอย่างที่โปรแกรมพิมพ์ออกมา เพื่อให้คุณตรวจได้
ให้ตรวจคำสำคัญ ไม่ใช่ตรวจช่องว่างให้ตรงเป๊ะ เทสต์ที่พังเพราะคุณเติมช่องว่างหนึ่งช่อง คือเทสต์ที่คุณจะเริ่มเมิน
🇨🇳 中文
你的 report 函数不返回任何东西,它是打印出来的。那就把打印的内容抓住。
capsys 是另一个 fixture。它会收集你程序打印的所有东西,好让你检查。
检查关键的词,不要检查空格是否完全一致。一个多打一个空格就失败的测试,你很快就会开始无视它。
def test_report_counts_each_student(db, capsys):
homework.add(db, Args(student="Ploy", title="a", due=None))
homework.add(db, Args(student="Nok", title="c", due=None))
homework.report(db, Args())
printed = capsys.readouterr().out
assert "Ploy" in printed
assert "Nok" in printed
6 Prove it really reached the disk · พิสูจน์ว่าข้อมูลถึงดิสก์จริง · 证明它真的写进了硬盘
🇬🇧 English
The whole point of the homework app was that it remembers. So test exactly that, and do not take the app's word for it.
Close the connection. Open the file again, from scratch, as if it were tomorrow. Then count.
This is the test that catches a missing commit(). Nothing else does, because in memory everything looks perfect.
🇹🇭 ไทย
หัวใจของแอปการบ้านคือมันจำได้ ฉะนั้นก็ทดสอบเรื่องนั้นตรง ๆ อย่าเชื่อคำของแอปเอง
ปิดการเชื่อมต่อ แล้วเปิดไฟล์ขึ้นมาใหม่ตั้งแต่ต้น เหมือนกับว่าเป็นวันพรุ่งนี้ แล้วค่อยนับ
นี่คือเทสต์ที่จับการลืม commit() ได้ ไม่มีเทสต์อื่นจับได้ เพราะในหน่วยความจำทุกอย่างดูสมบูรณ์แบบ
🇨🇳 中文
作业程序的全部意义就在于它记得住。那就直接测这一点,别听程序自己怎么说。
关掉连接,再从头把文件打开一次,就当是第二天。然后数一数。
这就是能抓住漏掉 commit() 的那个测试。别的都抓不到,因为在内存里一切看起来都很完美。
def test_the_data_is_really_on_disk(db, tmp_path):
homework.add(db, Args(student="Ploy", title="Unit 3", due=None))
db.close()
fresh = sqlite3.connect(tmp_path / "test.db") # open it again
assert fresh.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] == 1
fresh.close()
7 A green tick is not proof · เครื่องหมายถูกสีเขียวไม่ใช่ข้อพิสูจน์ · 绿色的勾不等于证明
🇬🇧 English
One last honest thing, and it matters more than any of the code above.
While making another lesson on this site, a checking program reported 8 out of 8 passed on work that was clearly wrong. The checks were counting letters when the real question was whether the answers were true. It measured the wrong thing, perfectly.
So tests tell you your code does what you asked. They cannot tell you that you asked for the right thing. Read the output sometimes. Keep asking what your test is really checking.
🇹🇭 ไทย
เรื่องสุดท้ายที่ต้องพูดกันตรง ๆ และสำคัญกว่าโค้ดทั้งหมดข้างบน
ตอนทำอีกบทเรียนหนึ่งของเว็บนี้ โปรแกรมตรวจรายงานว่า ผ่าน 8 จาก 8 ทั้งที่ผลงานผิดชัด ๆ เพราะมันนับตัวอักษร ทั้งที่คำถามจริงคือคำตอบนั้นถูกต้องหรือเปล่า มันวัดผิดเรื่อง แต่วัดได้อย่างสมบูรณ์แบบ
ฉะนั้นเทสต์บอกได้ว่าโค้ดทำตามที่คุณสั่ง แต่บอกไม่ได้ว่าคุณสั่งถูกหรือเปล่า ให้อ่านผลลัพธ์บ้าง และถามตัวเองเสมอว่าเทสต์ของคุณกำลังตรวจอะไรกันแน่
🇨🇳 中文
最后一句老实话,它比上面所有代码都重要。
在做这个网站的另一课时,一个检查程序报告8 项全部通过,可那份成果明显是错的。那些检查数的是字符,而真正的问题是答案对不对。它把错误的东西量得非常准确。
所以测试能告诉你代码做到了你要求的事,但不能告诉你你要求得对不对。偶尔看看输出,经常问问自己:我的测试到底在检查什么。
8 The whole test file · ไฟล์เทสต์ทั้งหมด · 完整的测试文件
🇬🇧 English
Sixteen tests, and they all run in about a sixth of a second. Put the file next to homework.py.
🇹🇭 ไทย
สิบหกเทสต์ รันทั้งหมดในเวลาราวหนึ่งในหกวินาที วางไฟล์ไว้ข้าง ๆ homework.py
🇨🇳 中文
十六个测试,全部跑完大约六分之一秒。把文件放在 homework.py 旁边。
python3 -m pytest -q
python3 -m pytest -v # one line per test
python3 -m pytest -k name # only tests with "name" in them
python3 -m pytest -x # stop at the first failure
9 Now change it · ลองแก้ดู · 现在动手改它
🇬🇧 English
The habit worth keeping: when you find a bug, write the test first, watch it fail, then fix the bug.
🇹🇭 ไทย
นิสัยที่ควรติดตัวไว้ คือเมื่อเจอบั๊ก ให้เขียนเทสต์ก่อน ดูให้เห็นว่ามันล้มเหลว แล้วค่อยแก้บั๊ก
🇨🇳 中文
值得养成的习惯:发现 bug 时,先写测试,看着它失败,然后再去修。
| Try this | How | Level |
|---|---|---|
| Test the due date | Add a job with a date, read it back, assert it matches. | easy |
| Add more awkward names | One more line in the parametrize list. | easy |
| Test an empty report | Call report with no jobs. It should not crash. | easy |
| Test the air program | Test band_for and bar from the air lesson. No internet needed. | harder |
| Test the web page | The school board has a test client. Post a job, assert the redirect. | harder |
| Find a real bug | Write a test you think will pass. Sometimes it will not. | harder |