Skip to content

Commit 3abc42c

Browse files
authored
docs(python): Enhance documentation for Python testing strategies (#306)
## What - SSIA ## CHECKS ⚠️ Please make sure you are aware of the following. - [ ] **The changes in this PR doesn't have private information
2 parents 95eb1f2 + 134ec33 commit 3abc42c

File tree

3 files changed

+272
-13
lines changed

3 files changed

+272
-13
lines changed

document/06-testing.en.md

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,97 @@ func TestSayHelloWithTime(t *testing.T) {
233233

234234
This demonstrates writing code with testing in mind.
235235

236+
### Python
237+
238+
# Python Testing Strategies
239+
240+
**:book: Reference**
241+
242+
(EN)[pytest: helps you write better programs — pytest documentation](https://docs.pytest.org/en/stable/)
243+
(EN)pytest fixtures: explicit, modular, scalable — pytest documentation
244+
(EN)Parametrizing fixtures and test functions — pytest documentation
245+
246+
While Python has the standard unittest library built-in for testing, the **pytest** library is widely used to write more flexible and readable tests. pytest comes with a simple API and powerful features, and can be easily installed with `pip install pytest`. You can run tests with the `$ pytest` command.
247+
248+
In Python, you can use the `pytest.mark.parametrize` decorator to describe multiple test cases together. Let's write a test for the say_hello function:
249+
250+
```python
251+
# hello.py
252+
def say_hello(name=""):
253+
if name:
254+
return f"Hello, {name}!"
255+
return "Hello!"
256+
257+
# test_hello.py
258+
import pytest
259+
from hello import say_hello
260+
261+
@pytest.mark.parametrize("name, expected",[
262+
("Alice", "Hello, Alice!"),
263+
("", "Hello!"),
264+
]
265+
)
266+
def test_say_hello(name, expected):
267+
got = say_hello(name)
268+
269+
# Check if the expected return value and the actual value are the same, and display an error if they differ
270+
assert got == expected, f"unexpected result of say_hello: want={expected}, got={got}"
271+
```
272+
273+
The need to consider argument design with testing in mind is common to both Python and Go. Let's consider modifying the `say_hello` implementation to change the greeting based on the time of day:
274+
275+
```python
276+
from datetime import datetime
277+
278+
def say_hello(name):
279+
now = datetime.now() # Directly depends on the current time, making it difficult to test
280+
current_hour = now.hour
281+
282+
if 6 <= current_hour < 10:
283+
return f"Good morning, {name}!"
284+
if 10 <= current_hour < 18:
285+
return f"Hello, {name}!"
286+
return f"Good evening, {name}!"
287+
```
288+
289+
This function is difficult to test because it directly depends on the current time. To test each time period, you would need to run the test at that specific time.
290+
291+
To make it more testable, we can rewrite the function as follows:
292+
293+
```python
294+
# Improved code (more testable design)
295+
from datetime import datetime
296+
297+
def say_hello(name, now=None):
298+
if now is None:
299+
now = datetime.now()
300+
301+
current_hour = now.hour
302+
303+
if 6 <= current_hour < 10:
304+
return f"Good morning, {name}!"
305+
if 10 <= current_hour < 18:
306+
return f"Hello, {name}!"
307+
return f"Good evening, {name}!"
308+
```
309+
310+
Now we can specify the current time as an argument. By setting None as the default value, we can still omit the now parameter in normal usage.
311+
312+
```python
313+
import pytest
314+
from datetime import datetime
315+
from greetings import say_hello
316+
317+
@pytest.mark.parametrize("name, now, expected", [
318+
("Alice", datetime(2024, 1, 1, 9, 0, 0), "Good morning, Alice!"),
319+
("Bob", datetime(2024, 1, 1, 12, 0, 0), "Hello, Bob!"),
320+
("Charlie", datetime(2024, 1, 1, 20, 0, 0), "Good evening, Charlie!"),
321+
])
322+
def test_say_hello_simple(name, now, expected):
323+
got = say_hello(name, now)
324+
assert got == expected, f"unexpected result of say_hello: want={expected}, got={got}"
325+
```
326+
236327
## 1. Writing Tests for the Item Listing API
237328

238329
Let's write tests for basic functionality, specifically testing item registration requests.
@@ -251,8 +342,11 @@ Let's write test cases for this.
251342
- What does this test verify?
252343
- What's the difference between `t.Error()` and `t.Fatal()`?
253344

254-
### Python
255-
TBD
345+
### Python (Read Only)
346+
347+
Python testing is implemented in [`main_test.py`](https://github.com/mercari-build/mercari-build-training/blob/main/python/main_test.py).
348+
349+
Unlike the Go API implementation, in Python API implementation using the FastAPI framework, developers do not need to implement HTTP Request parsing themselves. Therefore, no additional implementation is required in this chapter, but you should review the test code to deepen your understanding.
256350

257351
## 2. Writing Tests for the Hello Handler
258352

@@ -283,7 +377,16 @@ Once you have the logic figured out, implement it.
283377

284378
### Python
285379

286-
TBD
380+
- (En)[FastAPI > Learn > Tutorial - User Guide / Testing](https://fastapi.tiangolo.com/tutorial/testing/)
381+
382+
In Python, we use FastAPI's `testclient.TestClient` to verify that the handler function `hello` works correctly. Let's edit the test function [test_hello](https://github.com/mercari-build/mercari-build-training/blob/main/python/main_test.py#L53) that's already provided and write a test.
383+
384+
As with Go, let's implement the test code with the following considerations in mind:
385+
386+
- What do you want to test with this handler?
387+
- How can you verify that it behaves correctly?
388+
389+
For implementing the test, you may refer to the [official FastAPI documentation](https://fastapi.tiangolo.com/tutorial/testing/#testclient).
287390

288391
## 3. Writing Tests Using Mocks
289392

@@ -311,9 +414,24 @@ Let's test both successful and failed persistence scenarios using mocks.
311414
- Consider the benefits of using interfaces to satisfy mocks
312415
- Think about the pros and cons of using mocks
313416

314-
### Python
417+
### Python (Read Only)
315418

316-
TBD
419+
**:book: Reference**
420+
421+
- (EN) [pytest-mock](https://github.com/pytest-dev/pytest-mock)
422+
- (EN) [unittest.mock --- introduction](https://docs.python.org/3.13/library/unittest.mock-examples.html#)
423+
424+
For Python mock libraries, there are several options including the built-in standard `unittest.mock` and pytest's `pytest-mock`. Mocks become necessary when the process being tested depends on external tools or objects, such as in the following cases:
425+
426+
- Mocking database connections to test user authentication logic without connecting to an actual database.
427+
- Mocking HTTP API clients to test weather forecast retrieval functions without actual network communication.
428+
- Mocking the file system to test logging functionality without actual file operations.
429+
430+
In our case, we could consider implementing a test like the first example mentioned: "mocking database connections." However, the Build Python API implementation is very simple, and setting up classes like ItemRepository for mock testing would unnecessarily complicate the implementation.
431+
432+
Since sufficient verification can be done with the test code implemented in the chapter "4. Writing tests using actual databases," and because it would contradict Python's language philosophy of "simplicity" and "explicitness," **we have omitted Python implementations using mocks from this teaching material**.
433+
434+
However, in actual development environments where applications become more complex, there are many cases where tests using mocks are implemented in Python as well. If you're interested, take a look at the explanation of mock testing in the Go section, or review Python test implementations using mocks that are introduced on the internet.
317435

318436
## 4. Writing Tests Using Real Databases
319437

@@ -332,7 +450,17 @@ After performing database operations, we need to verify the database state match
332450

333451
### Python
334452

335-
TBD
453+
Let's write a test in Python using a test database (sqlite3). Uncomment the two places in [main_test.py](https://github.com/mercari-build/mercari-build-training/blob/main/python/main_test.py) that say "`STEP 6-4: uncomment this test setup`". ([first location](https://github.com/mercari-build/mercari-build-training/blob/main/python/main_test.py#L9-L42)/[second location](https://github.com/mercari-build/mercari-build-training/blob/main/python/main_test.py#L60-L84))
454+
455+
The `db_connection` function creates and sets up a new test database using sqlite3 before the test, and deletes the test database after the test is completed.
456+
457+
The `test_add_item_e2e` function tests the item addition functionality by sending a POST request to the API endpoint (`/items/`). This function runs with parameterized test cases (valid and invalid data). The test verifies:
458+
459+
1. Whether the response status code matches the expected value
460+
2. For non-error cases, whether the response body contains a "message"
461+
3. Whether the data was correctly saved in the database (matching name and category)
462+
463+
What's particularly important is that it tests end-to-end using an actual database (for testing) rather than mocks, which verifies the functionality in a way that's closer to the actual environment.
336464

337465
## Next
338466

document/06-testing.ja.md

Lines changed: 137 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,97 @@ func TestSayHelloWithTime(t *testing.T) {
233233

234234
このようにして、テストのことも意識したコードをかけると良いですね。
235235

236+
### Python
237+
238+
Pythonにおけるテスト戦略
239+
**:book: Reference**
240+
241+
(EN)[pytest: helps you write better programs — pytest documentation](https://docs.pytest.org/en/stable/)
242+
(EN)pytest fixtures: explicit, modular, scalable — pytest documentation
243+
(EN)Parametrizing fixtures and test functions — pytest documentation
244+
245+
Pythonにはtest用のライブラリとして標準搭載されているunittestがありますが、より柔軟で可読性の高いテストを書くために**pytest**というライブラリが広く利用されています。pytestはシンプルなAPIと強力な機能を備えており、`pip install pytest`で簡単にインストールできます。`$ pytest`コマンドでテストを実行することができます。
246+
247+
248+
Pythonでは`pytest.mark.parametrize`デコレータを使って複数のテストケースをまとめて記述できます。say_hello関数のテストを書いてみましょう。
249+
250+
```python
251+
# hello.py
252+
def say_hello(name=""):
253+
if name:
254+
return f"Hello, {name}!"
255+
return "Hello!"
256+
257+
# test_hello.py
258+
import pytest
259+
from hello import say_hello
260+
261+
@pytest.mark.parametrize("name, expected",[
262+
("Alice", "Hello, Alice!"),
263+
("", "Hello!"),
264+
]
265+
)
266+
def test_say_hello(name, expected):
267+
got = say_hello(name)
268+
269+
# 期待する返り値と実際に得た値が同じか確認した上で, 期待する返り値と実際に得た値が異なる場合は、エラーを表示
270+
assert got == expected, f"unexpected result of say_hello: want={expected}, got={got}"
271+
```
272+
273+
テストを想定して、引数の設計を考える必要があるという点はPythonもGoと共通です。`say_hello`の実装を時間に応じて挨拶を変えるように変更することを想定します。
274+
275+
```python
276+
from datetime import datetime
277+
278+
def say_hello(name):
279+
now = datetime.now() # 現在時刻に直接依存しているため、テストしにくい
280+
current_hour = now.hour
281+
282+
if 6 <= current_hour < 10:
283+
return f"Good morning, {name}!"
284+
if 10 <= current_hour < 18:
285+
return f"Hello, {name}!"
286+
return f"Good evening, {name}!"
287+
```
288+
289+
この関数は現在時刻に直接依存しているため、テストが難しい設計です。各時間帯をテストするためには、実際にその時間にテストを実行する必要があります。
290+
291+
テストしやすくするために、関数を次のように書き換えます:
292+
293+
```python
294+
# 改善されたコード(テストしやすい設計)
295+
from datetime import datetime
296+
297+
def say_hello(name, now=None):
298+
if now is None:
299+
now = datetime.now()
300+
301+
current_hour = now.hour
302+
303+
if 6 <= current_hour < 10:
304+
return f"Good morning, {name}!"
305+
if 10 <= current_hour < 18:
306+
return f"Hello, {name}!"
307+
return f"Good evening, {name}!"
308+
```
309+
310+
これで現在時刻を引数として指定できるようになりました。デフォルト値としてNoneを設定することで、通常の使用ではnowを省略することもできます。
311+
312+
```python
313+
import pytest
314+
from datetime import datetime
315+
from greetings import say_hello
316+
317+
@pytest.mark.parametrize("name, now, expected", [
318+
("Alice", datetime(2024, 1, 1, 9, 0, 0), "Good morning, Alice!"),
319+
("Bob", datetime(2024, 1, 1, 12, 0, 0), "Hello, Bob!"),
320+
("Charlie", datetime(2024, 1, 1, 20, 0, 0), "Good evening, Charlie!"),
321+
])
322+
def test_say_hello_simple(name, now, expected):
323+
got = say_hello(name, now)
324+
assert got == expected, f"unexpected result of say_hello: want={expected}, got={got}"
325+
```
326+
236327
## 1. 出品APIのテストを書く
237328
基礎的な機能のテストである、アイテム登録のためのリクエストのテストを書いてみましょう。
238329

@@ -250,8 +341,12 @@ func TestSayHelloWithTime(t *testing.T) {
250341
- このテストは何を検証しているでしょうか?
251342
- `t.Error()``t.Fatal()` には、どのような違いがあるでしょうか?
252343

253-
### Python
254-
TBD
344+
### Python(Read Only)
345+
346+
Pythonのテストは[`main_test.py`](https://github.com/mercari-build/mercari-build-training/blob/main/python/main_test.py)に実装されています。
347+
348+
GoのAPI実装と異なり、FastAPIというフレームワークを活用したPythonのAPI実装ではHTTP RequestをParseする処理を開発者で実装する必要がありません。そのため、本章で追加の実装は必要ありませんが、テストコードに目を通し、理解を深めておきましょう。
349+
255350

256351
## 2. Hello Handlerのテストを書く
257352
ハンドラのテストを書いてみましょう。
@@ -281,7 +376,16 @@ Goでは、 `httptest` と呼ばれるハンドラをテストするためのラ
281376

282377
### Python
283378

284-
TBD
379+
- (JA)[FastAPI > チュートリアル > ユーザーガイド / テスト](https://fastapi.tiangolo.com/ja/tutorial/testing/)
380+
381+
Pythonでは、FastAPIが提供する`testclient.TestClient`を用いて、ハンドラとなる`hello`が正しく動作するかどうかを検証します。すでに用意されているテスト用の関数の[test_hello](https://github.com/mercari-build/mercari-build-training/blob/main/python/main_test.py#L53)を編集して、テストを書いてみましょう。
382+
383+
Goと同じように以下のことを意識しながら、テストコードを実装してみましょう。
384+
385+
- このハンドラでテストしたいのは何でしょうか?
386+
- それが正しい振る舞いをしていることはどのようにして確認できるでしょうか?
387+
388+
テストの実装には、[FastAPIの公式document](https://fastapi.tiangolo.com/ja/tutorial/testing/#testclient)を参考にしてみてください。
285389

286390
## 3. モックを用いたテストを書く
287391
モックを用いたテストを書いてみましょう。
@@ -309,7 +413,23 @@ Goには様々なモックライブラリがありますが、今回は `gomock`
309413
- モックを利用するメリットとデメリットについて考えてみましょう
310414

311415
### Python
312-
TBD
416+
417+
**:book: Reference**
418+
419+
- (EN) [pytest-mock](https://github.com/pytest-dev/pytest-mock)
420+
- (JA) [unittest.mock --- 入門](https://docs.python.org/ja/3.13/library/unittest.mock-examples.html#)
421+
422+
Pythonのmock用のライブラリとしては標準搭載された`unittest.mock`やpytestが提供する`pytest-mock`等の選択肢が存在します。モックが必要になるケースとしては、テスト対象となる処理が外部的なツールやオブジェクトに依存する場合、例えば以下のようなケースが挙げられます。
423+
424+
- データベース接続をモックして、実際のDBに接続せずにユーザー認証ロジックをテストする。
425+
- HTTP APIクライアントをモックして、実際のネットワーク通信なしで天気予報取得関数をテストする。
426+
- ファイルシステムをモックして、実際のファイル操作なしでログ出力機能をテストする。
427+
428+
今回の場合、例の一番最初にあげた「データベース接続をモックする」というテストの実装が考えられます。しかし、BuildのPythonによるAPI実装は非常にシンプルなもので、モックしたテストを書くために、ItemRepositoryのようなクラスを設けることは必要以上に実装を複雑にしてしまいます。
429+
430+
「4. 実際のデータベースを用いたテストを書く」という章で実装するテストコードで十分な検証ができる上に、Pythonの「シンプルさ」と「明示的であること」を重視する言語哲学にも反すると考え、**今回の教材からはmockを用いたpythonの実装を省いています**
431+
432+
ただし、実際の開発現場のように、アプリケーションが複雑化した場合はmockを用いたテストの実装をPythonでも実施するケースが多いです。興味があるという方は、Goの方のmockを用いたテストの説明に目を通してみたり、インターネット上で紹介されているmockを用いたpythonのテスト実装に目を通してみましょう。
313433

314434
## 4. 実際のデータベースを用いたテストを書く
315435
STEP 6-3におけるモックを実際のデータベースに置き換えたテストを書いてみましょう。
@@ -325,7 +445,19 @@ Goでは、テスト用にデータベース用のファイルを作成して、
325445
- それが正しい振る舞いをしていることはどのようにして確認できるでしょうか?
326446

327447
### Python
328-
TBD
448+
449+
Pythonで、テスト用のデータベース(sqlite3)を用いたテストを書いていきましょう。[main_test.py]()の「`STEP 6-4: uncomment this test setup`」という記載がある二カ所をコメントアウトしてください。([一カ所目](https://github.com/mercari-build/mercari-build-training/blob/main/python/main_test.py#L9-L42)/[二カ所目](https://github.com/mercari-build/mercari-build-training/blob/main/python/main_test.py#L60-L84))
450+
451+
452+
`db_connection`関数で、テスト前にはsqlite3を用いたテスト用のdbの新規作成とセットアップを行い、テストの終了後にはテスト用のdbを削除する処理を行なっています。
453+
454+
`test_add_item_e2e`では、APIのエンドポイント(`/items/`)に対してPOSTリクエストを送信し、アイテム追加機能をテストしています。この関数は複数のテストケース(有効なデータと無効なデータ)をパラメータ化して実行します。テストでは以下を検証します:
455+
456+
1. レスポンスのステータスコードが期待値と一致するか
457+
2. エラーでない場合は、レスポンスボディに「message」が含まれているか
458+
3. データベースに正しくデータが保存されたか(名前とカテゴリが一致するか)
459+
460+
特に重要なのは、モックではなく実際のデータベース(テスト用)を使用してエンドツーエンドでテストすることで、実際の環境に近い形で機能を検証している点です。
329461

330462
## Next
331463

python/main_test.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# finally:
1717
# conn.close()
1818

19+
# app.dependency_overrides[get_db] = override_get_db
1920

2021
# @pytest.fixture(autouse=True)
2122
# def db_connection():
@@ -39,8 +40,6 @@
3940
# if test_db.exists():
4041
# test_db.unlink() # Remove the file
4142

42-
# app.dependency_overrides[get_db] = override_get_db
43-
4443
client = TestClient(app)
4544

4645

0 commit comments

Comments
 (0)