プログラミング学習 Day 9:Software test, pytest

Pytest

ソフトウエアテストの続きです。

Pythonで使われる単体テスト/統合テストのツール、pytestの紹介です。


主な機能と特徴

pytestはPython用のテストフレームワークで、テストの作成・実行・管理を簡単にしてくれるツールです。読みやすく書けて拡張性が高く、ユニットテストから統合テストまで幅広く使われます。

  • テストの自動検出:ファイル名 test_*.py / *_test.py、関数名 test_* を自動で見つけて実行します。
  • アサーション:普通の assert をそのまま使える(失敗時にわかりやすい差分を表示する「assert rewriting」)。
  • フィクスチャ(fixtures:セットアップ/後片付けを共通化してテストに注入できる(DB接続、モック、テストデータなど)。
  • パラメータ化:pytest.mark.parametrize で同じテストを複数の入力で回せる。
  • プラグインエコシステム:pytest-cov(カバレッジ)、pytest-xdist(並列実行)、pytest-mock 等、多数のプラグインがある。
  • キャプチャ・ログ管理:標準出力やログのキャプチャ、検査が簡単。
  • 柔軟なフィルタ/マーク:-k(キーワード選択)、-m(マーカー選択)で特定のテストだけ実行できる。
  • – CIに組み込みやすい:Jenkins/GitHub Actionsなどと相性が良い。




インストールと基本的な実行

  • – インストール: pip install pytest
  • – 実行(プロジェクトルートで): pytest
  • – 簡潔表示: pytest -q
  • – 特定ファイルだけ: pytest tests/test_example.py
簡単な例
tests/test_sample.py
```
def add(a, b):
return a + b

def test_add():
assert add(1, 2) == 3
```

フィクスチャ(簡単な例)
conftest.py
```
import pytest

@pytest.fixture
def user():
return {"id": 1, "name": "Alice"}
```
テストで使う:
```
def test_user_name(user):
assert user["name"] == "Alice"
```

よく使うオプション・コマンド
- -k "keyword":名前にkeywordが含まれるテストだけ実行
- -m "marker":マーク付けしたテストだけ実行
- -q / -vv:出力の簡潔さ/詳細度を変更
- --maxfail=1:最初の失敗で止める
- pytest --cov=パッケージ:カバレッジ計測(pytest-covが必要)

中規模の例

中規模のpytest サンプルプロジェクトです。
ユニットテスト、インテグレーション風テスト、fixture(ファクトリ・DB初期化・ファイルリソースなど)、パラメータ化、monkeypatch/キャプチャの例を含みます。

主なポイント

  • – src/app にアプリケーションコード(計算ロジックと簡単な SQLite ストア)
  • – conftest.py に実用的な fixture 群(カウンターファクトリ、DB 接続、永続化用ファクトリ)
  • – tests フォルダにユニット/統合テストの例
  • – pytest.ini にマーカー定義とデフォルト addopts

セットアップ

1. venv 作成(推奨)
python3 -m venv .venv
source .venv/bin/activate

2. 依存インストール
pip install -r requirements.txt

3.テスト実行
pytest
pytest -k integration -m integration
pytest tests/test_store.py::test_posts_roundtrip -q

構成

- src/app/*.py : サンプルアプリケーションコード(calc, store)
- conftest.py : 共有 fixture(db_conn, persisted_user_factory...)
- tests/ : ユニット・統合テスト

以下のファイルをそのままプロジェクトに追加すれば、例題が実行できます。
```python name=src/app/calc.py
# 小さな計算モジュール(ユニットテスト向け)
def add(a, b):
return a + b

def divide(a, b):
if b == 0:
raise ZeroDivisionError("division by zero")
return a / b

def greet(name):
print(f"Hello, {name}!")
return f"Hello, {name}!"
```

```python name=src/app/store.py
# シンプルな SQLite ベースのストア(ファイル or :memory: に接続して使う)
import sqlite3
from typing import List, Dict

_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
body TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
"""

def init_db(conn: sqlite3.Connection) -> None:
conn.executescript(_SCHEMA)
conn.commit()

def add_user(conn: sqlite3.Connection, name: str, email: str) -> int:
cur = conn.execute("INSERT INTO users(name, email) VALUES (?, ?)", (name, email))
conn.commit()
return cur.lastrowid

def get_user(conn: sqlite3.Connection, user_id: int) -> Dict:
cur = conn.execute("SELECT id, name, email FROM users WHERE id = ?", (user_id,))
row = cur.fetchone()
return {"id": row[0], "name": row[1], "email": row[2]} if row else None

def add_post(conn: sqlite3.Connection, user_id: int, title: str, body: str = "") -> int:
cur = conn.execute("INSERT INTO posts(user_id, title, body) VALUES (?, ?, ?)", (user_id, title, body))
conn.commit()
return cur.lastrowid

def get_posts_by_user(conn: sqlite3.Connection, user_id: int) -> List[Dict]:
cur = conn.execute("SELECT id, title, body FROM posts WHERE user_id = ?", (user_id,))
return [{"id": r[0], "title": r[1], "body": r[2]} for r in cur.fetchall()]
```

```python name=conftest.py
# プロジェクト共通の pytest fixtures(ファクトリ、DB、一時ファイルなど)
import itertools
import sqlite3
from typing import Callable, Dict
import pytest
from pathlib import Path

from src.app import store

def _make_counter():
return itertools.count(1)

@pytest.fixture(scope="session")
def project_root(tmp_path_factory):
# テスト実行セッションにつき一度だけ作られるルート(必要なら拡張可能)
p = tmp_path_factory.mktemp("project")
return p

@pytest.fixture
def user_factory() -> Callable[..., Dict]:
"""
インメモリ用の user dict factory(DB を使わない単体テスト用)
テストごとに id が 1,2,... とリセットされる(function scope)
"""
counter = _make_counter()
def _user(**overrides):
i = next(counter)
data = {"id": i, "name": overrides.get("name", f"User {i}"), "email": overrides.get("email", f"user{i}@example.com")}
data.update(overrides)
return data
return _user

@pytest.fixture
def db_path(tmp_path):
"""
インテグレーション的テスト向け:一時的な SQLite ファイル DB のパスを返す
"""
return str(tmp_path / "test.db")

@pytest.fixture
def db_conn(db_path):
"""
DB 接続を返す fixture。テストごとにファイル DB に接続し schema を初期化する。
必要に応じて :memory: に切り替え可能。
"""
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA foreign_keys = ON;")
store.init_db(conn)
yield conn
try:
conn.close()
except Exception:
pass

@pytest.fixture
def persisted_user_factory(db_conn) -> Callable[..., Dict]:
"""
DB にユーザーを挿入して返すファクトリ
"""
counter = _make_counter()
def _puser(**overrides):
i = next(counter)
name = overrides.get("name", f"DBUser {i}")
email = overrides.get("email", f"dbuser{i}@example.com")
uid = store.add_user(db_conn, name, email)
data = {"id": uid, "name": name, "email": email}
data.update(overrides)
return data
return _puser

@pytest.fixture
def persisted_post_factory(db_conn, persisted_user_factory) -> Callable[..., Dict]:
"""
DB に post を挿入して返すファクトリ
"""
counter = _make_counter()
def _ppost(user=None, **overrides):
i = next(counter)
if user is None:
user = persisted_user_factory()
title = overrides.get("title", f"Post {i}")
body = overrides.get("body", "")
pid = store.add_post(db_conn, user["id"], title, body)
data = {"id": pid, "title": title, "body": body, "user": user}
data.update(overrides)
return data
return _ppost
```

```ini name=pytest.ini
[pytest]
minversion = 6.0
addopts = -ra -q
testpaths = tests
markers =
integration: mark tests as integration tests that touch DB or filesystem
slow: mark slow tests
```

```text name=requirements.txt
pytest
pytest-cov
```


```python name=tests/test_calc.py
from src.app import calc

def test_add():
assert calc.add(1, 2) == 3
assert calc.add(-1, 1) == 0

def test_divide():
assert calc.divide(6, 3) == 2.0

def test_divide_by_zero():
import pytest
with pytest.raises(ZeroDivisionError):
calc.divide(1, 0)

def test_greet(capsys):
res = calc.greet("Alice")
captured = capsys.readouterr()
assert "Hello, Alice!" in captured.out
assert res == "Hello, Alice!"
```

```python name=tests/test_store.py
import pytest
from src.app import store

@pytest.mark.integration
def test_user_roundtrip(db_conn, persisted_user_factory):
# persisted_user_factory は DB にユーザーを追加して返す
u = persisted_user_factory(name="Alice DB")
got = store.get_user(db_conn, u["id"])
assert got is not None
assert got["name"] == "Alice DB"
assert got["email"] == u["email"]

@pytest.mark.integration
def test_posts_roundtrip(db_conn, persisted_user_factory, persisted_post_factory):
u = persisted_user_factory(name="Poster")
p1 = persisted_post_factory(user=u, title="Post 1", body="Body 1")
p2 = persisted_post_factory(user=u, title="Post 2", body="Body 2")
posts = store.get_posts_by_user(db_conn, u["id"])
titles = {p["title"] for p in posts}
assert "Post 1" in titles and "Post 2" in titles

def test_in_memory_isolation():
# :memory: を直接使う単体テスト例(DBの isolation を確認する用途)
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("PRAGMA foreign_keys = ON;")
store.init_db(conn)
uid = store.add_user(conn, "M", "m@example.com")
assert store.get_user(conn, uid)["name"] == "M"
conn.close()
```

```python name=tests/test_fixtures_and_param.py
import pytest

@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(5, -5, 0),
(0, 0, 0),
])
def test_add_parametrized(a, b, expected):
from src.app import calc
assert calc.add(a, b) == expected

def test_user_factory_simple(user_factory):
u1 = user_factory()
u2 = user_factory()
assert u1["id"] != u2["id"]
assert u1["email"].startswith("user")

def test_monkeypatch_env(monkeypatch):
import os
monkeypatch.setenv("APP_ENV", "test-env")
assert os.environ["APP_ENV"] == "test-env"
```

使用例(実行例)

- pytest -q
- pytest -q -m "integration" <-- DB / filesystem に触れるテストだけ実行
- pytest --maxfail=1 -k "divide" <-- divide に関するテストだけ

注意点
- persisted_* fixtures は sqlite ファイル DB を使います。

参考

前回で説明しましたPASCALのPerson ClassのPython版です。その単体テストを実行します。

PersonClass.py

#!/usr/bin/env python3
"""
PersonClass.py

A Python translation of the Pascal PersonClass example.
Provides:
- Person: a normal class with constructor (__init__), properties, methods.
- PersonData: a dataclass alternative for lightweight immutable-ish records.
- Demonstration in __main__ showing usage.
"""
from dataclasses import dataclass, asdict
from typing import Optional


class Person:
"""Simple Person class with constructor, methods and property validation."""

def __init__(self, name: str, age: int) -> None:
# Basic validation
if not isinstance(name, str):
raise TypeError("name must be a string")
if not isinstance(age, int):
raise TypeError("age must be an integer")
if age < 0:
raise ValueError("age must be non-negative")

self._name = name
self._age = age
# (No explicit destructor needed; Python GC handles cleanup.)

# property for name
@property
def name(self) -> str:
return self._name

@name.setter
def name(self, value: str) -> None:
if not isinstance(value, str):
raise TypeError("name must be a string")
self._name = value

# property for age
@property
def age(self) -> int:
return self._age

@age.setter
def age(self, value: int) -> None:
if not isinstance(value, int):
raise TypeError("age must be an integer")
if value < 0:
raise ValueError("age must be non-negative")
self._age = value

def greet(self) -> str:
"""Return a greeting string (does not print directly)."""
return f"Hello, my name is {self._name} and I am {self._age} years old."

def is_adult(self) -> bool:
return self._age >= 18

def to_dict(self) -> dict:
"""Serialize to dict."""
return {"name": self._name, "age": self._age}

@classmethod
def from_dict(cls, data: dict) -> "Person":
"""Construct from dict with minimal validation."""
name = data.get("name")
age = data.get("age")
return cls(name, age)

def __repr__(self) -> str:
return f"Person(name={self._name!r}, age={self._age})"


@dataclass
class PersonData:
"""A concise dataclass alternative. Use when you prefer minimal boilerplate."""
name: str
age: int

def greet(self) -> str:
return f"Hello, my name is {self.name} and I am {self.age} years old."

def is_adult(self) -> bool:
return self.age >= 18

def to_dict(self) -> dict:
return asdict(self)


def _demo():
# Using Person class
p = Person("Alice", 30)
print(p.greet())
print("Is adult:", p.is_adult())
p.name = "Alice Smith"
p.age = 31
print("Updated:", p)

# from_dict / to_dict
d = p.to_dict()
p2 = Person.from_dict(d)
print("From dict:", p2)

# Using PersonData (dataclass)
pd = PersonData("Bob", 16)
print(pd.greet())
print("pd is adult:", pd.is_adult())
print("pd as dict:", pd.to_dict())

# Demonstrate validation errors
try:
Person("Carol", -1)
except ValueError as e:
print("Validation:", e)


if __name__ == "__main__":
_demo()

Pytestによる単体テストのためのプログラム

tests/test_peronclass.py
import pytest
from PersonClass import Person, PersonData


def test_person_creation_and_methods():
p = Person("Alice", 30)
assert p.name == "Alice"
assert p.age == 30
assert p.greet() == "Hello, my name is Alice and I am 30 years old."
assert p.is_adult() is True
assert p.to_dict() == {"name": "Alice", "age": 30}
# repr は正しく name と age を含むこと
r = repr(p)
assert "Person(name=" in r and "Alice" in r and "30" in r


def test_person_setters_and_validation():
p = Person("Bob", 20)
p.name = "Bobby"
assert p.name == "Bobby"
p.age = 25
assert p.age == 25

with pytest.raises(TypeError):
p.name = 123 # name must be string

with pytest.raises(TypeError):
p.age = "old" # age must be int

with pytest.raises(ValueError):
p.age = -1 # negative age not allowed


def test_person_init_validation():
with pytest.raises(TypeError):
Person(123, 10) # name not string

with pytest.raises(TypeError):
Person("Carol", "thirty") # age not int

with pytest.raises(ValueError):
Person("Dave", -5) # negative age


def test_from_dict_and_missing_keys():
d = {"name": "Eve", "age": 40}
p = Person.from_dict(d)
assert isinstance(p, Person)
assert p.name == "Eve"
assert p.age == 40

# from_dict with missing keys will pass None and should raise TypeError on construction
with pytest.raises(TypeError):
Person.from_dict({})


def test_persondata_behaviour_and_mutability():
pd = PersonData("Frank", 17)
assert pd.name == "Frank"
assert pd.age == 17
assert pd.greet() == "Hello, my name is Frank and I am 17 years old."
assert pd.is_adult() is False
assert pd.to_dict() == {"name": "Frank", "age": 17}

# dataclass fields are mutable by default; test mutation
pd.age = 18
assert pd.is_adult() is True

Pytestの実行

PersonClass.py をプロジェクトのルートに置き(または tests から import 可能な場所に置き)、tests/test_personclass.py を tests/ 配下に置いてください。
仮想環境作成、pytest インストール後に実行します:
python3 -m venv .venv
source .venv/bin/activate
pip install pytest
pytest -q




実行例

(.venv) (base) matsuo@DESKTOP-1295BP3:~/person$ python3 -m pytest -s -v
================================================= test session starts =====
platform linux -- Python 3.12.9, pytest-9.0.1, pluggy-1.6.0 -- /home/matsuo/person/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /home/matsuo/person
collected 5 items

tests/test_personclass.py::test_person_creation_and_methods PASSED
tests/test_personclass.py::test_person_setters_and_validation PASSED
tests/test_personclass.py::test_person_init_validation PASSED
tests/test_personclass.py::test_from_dict_and_missing_keys PASSED
tests/test_personclass.py::test_persondata_behaviour_and_mutability PASSED

================================================== 5 passed in 0.01s ======

著者:松尾正信
株式会社京都テキストラボ代表取締役