Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,23 @@ jobs:
severity: error
- name: Run ShellCheck on install.sh
run: shellcheck --severity=error install.sh

pytest:
name: Pytest (Python ${{ matrix.python-version }})
Comment thread
takemi-ohama marked this conversation as resolved.
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# requires-python の下限と、手元で使う版 (PLAN60 前提 1)
python-version: ["3.10", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies from uv.lock
run: uv sync --locked
- name: Run pytest
# CI に DEVBASE_ROOT は無い。テストは自前の tmp を DEVBASE_ROOT に向ける (PLAN60 前提 4)
run: uv run --locked pytest tests/ -q
76 changes: 76 additions & 0 deletions issues/PLAN60_ci-pytest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# PLAN60: CI で tests/ の pytest を実行する

対象 issue: devbasex/devbase#141

- ワークフローモード: `light`
- 根拠: 変えるのは CI の構成(`.github/workflows/ci.yml`)と、CI で通すためのテスト側の調整だけで、
本番の振る舞いも本番コード(`bin/` `lib/` `etc/` `containers/`)の構造も変えない

## 依頼(原文)

> `tests/` に pytest のテスト一式(`tests/cli` `tests/commands` `tests/project` `tests/snapshot` など 10 ディレクトリ)がありますが、`.github/workflows/ci.yml` はこれを実行しません。
>
> テストを足しても、それが壊れたことに CI が気づきません。
>
> 導入時には次を確認する必要があります。
>
> - `uv` のセットアップ手順(`pyproject.toml` / `uv.lock` がある)
> - 実 `docker` を必要とするテストがある場合の切り分け(マーカーか、CI では除外するか)

## 目的

- `tests/` のテストが壊れた変更を、Pull Request の時点で CI が落とすようにする

## 前提

- 前提 1: CI の Python は `pyproject.toml` の `requires-python` の下限と、手元で使う版(2026-09-18 時点で uv が選ぶ 3.13)の 2 つで足りる。
既存の `python-syntax` ジョブの行列(3.10 / 3.11 / 3.12)には合わせない。pytest の実行時間が 3 倍になる
割に、版の差で落ちる箇所は `compileall` の行列が既に構文で拾っている
- 前提 2: 依存は `uv.lock` から `uv sync --locked` で入れる。lock と `pyproject.toml` が食い違えば CI が落ちる
- 前提 3: 実 docker・実 OpenBao・ネットワークを要るテストは、CI では走らせない。手元で走る
テストのうち CI で落ちるものは、**テストの側を直して**(環境の隔離・スタブ)CI でも通るようにする。
`skip` で逃がすのは、実機そのものを確かめるテストに限る
- 前提 4: CI の実行環境に `DEVBASE_ROOT` は無い。手元の実行はシェルの `DEVBASE_ROOT` を継承するため、
両方で同じ結果になるテストだけを CI に載せる

## 対象範囲

含む:

- `.github/workflows/ci.yml` に pytest のジョブを足す
- CI の環境(`DEVBASE_ROOT` 無し・docker 無し・Linux)で落ちるテストの修正
- 開発者向け文書の CI の説明(`docs/developer/contributing.md` / `CONTRIBUTING.md` に記載があれば)

含まない:

- 本番コードの変更
- カバレッジの計測・閾値
- ruff の検査範囲の拡大(`--select` の追加)
- 既存ジョブ(`python-syntax` / `lint` / `shellcheck`)の変更

## 受け入れ条件

- [ ] 1. `main` 宛ての Pull Request と `main` への push で、`tests/` 全体の pytest を実行するジョブが走る
- [ ] 2. そのジョブは `uv.lock` から依存を入れ(`uv sync --locked` 相当)、lock に無い依存を取りに行かない
- [ ] 3. この Pull Request の CI で、そのジョブが成功する
- [ ] 4. テストを 1 件わざと失敗させた状態で、そのジョブが失敗する(確かめた後にその変更は戻す)
- [ ] 5. 手元(`DEVBASE_ROOT` を持つシェル)の `uv run pytest tests/` の結果が、この変更の前後で変わらない
(通っていたテストが落ちない。件数の変化は CI 向けに skip へ回したものの数と一致する)
- [ ] 6. CI で skip するテストがあれば、1 件ごとに skip の理由がテストの中に書かれ、実機を要するテストに限られる

## 検証手段

| 項目 | 手段 |
| --- | --- |
| テスト(手元) | `uv run pytest tests/ -q` |
| テスト(CI 相当) | `env -u DEVBASE_ROOT uv run --locked pytest tests/ -q` |
| CI | Pull Request の checks(`gh pr checks`) |
| 静的解析 | 既存の `python-syntax` / `lint` / `shellcheck` のジョブ |

## 境界

| 区分 | 内容 |
| --- | --- |
| 常に行う | 手元と CI 相当の両方で pytest を回す |
| 確認してから行う | テストを skip へ回すこと(理由を条件 6 のとおり残す) |
| 行わない | 本番コードの変更、既存ジョブの変更 |
26 changes: 20 additions & 6 deletions tests/containers/test_entrypoint_startup_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import os
import shutil
import subprocess
from pathlib import Path

Expand All @@ -16,8 +17,6 @@
ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh"


# gcloud を含まない最小の PATH。`/nonexistent` にすると bash 自体も見つからない。
MINIMAL_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"


def run(script: str, cwd: Path, path: str | None = None):
Expand Down Expand Up @@ -45,6 +44,21 @@ def install(script: str):
return install


@pytest.fixture
def no_gcloud_path(tmp_path: Path) -> str:
"""``bash`` だけを置いた PATH (gcloud を含まない)。

システムの ``/usr/bin`` を並べるだけでは足りない。GitHub Actions の Ubuntu の
runner は ``/usr/bin/gcloud`` を持つ (PLAN60)。`/nonexistent` にすると bash 自体も
見つからないため、bash への symlink だけを持つディレクトリを作る。
entrypoint.sh の関数定義より前のトップレベルは外部コマンドを呼ばない。
"""
d = tmp_path / "minimal-bin"
d.mkdir()
(d / "bash").symlink_to(shutil.which("bash"))
return str(d)


def test_group_and_account_are_reported(tmp_path, fake_bin):
path = fake_bin('echo "someone@example.com"')

Expand Down Expand Up @@ -77,16 +91,16 @@ def test_empty_account_is_reported_as_unset(tmp_path, fake_bin):
assert "gcloud account: unset" in result.stdout


def test_missing_gcloud_is_reported(tmp_path):
def test_missing_gcloud_is_reported(tmp_path, no_gcloud_path):
"""gcloud を含まないイメージでも落ちない。"""
result = run('devbase_log_account_group "default"', tmp_path, path=MINIMAL_PATH)
result = run('devbase_log_account_group "default"', tmp_path, path=no_gcloud_path)

assert result.returncode == 0, result.stderr
assert "gcloud not installed" in result.stdout


def test_group_defaults_when_omitted(tmp_path):
result = run('devbase_log_account_group', tmp_path, path=MINIMAL_PATH)
def test_group_defaults_when_omitted(tmp_path, no_gcloud_path):
result = run('devbase_log_account_group', tmp_path, path=no_gcloud_path)

assert result.returncode == 0, result.stderr
assert "Account group: default" in result.stdout
Expand Down
19 changes: 14 additions & 5 deletions tests/containers/test_tmux_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,20 @@ def test_default_terminal_is_tmux_256color(options):


@needs_tmux
def test_terminal_overrides_appends_without_dropping_defaults(options):
"""条件 5: `set -ga` で追記し、tmux 既定の linux*:AX@ を残す。"""
overrides = options["terminal-overrides"]
assert "xterm-256color:Tc" in overrides
assert "linux*:AX@" in overrides
def test_terminal_overrides_appends_without_dropping_defaults(options, tmp_path):
"""条件 5: `set -ga` で追記し、tmux 既定の値 (3.x 前半の linux*:AX@ など) を残す。

既定の値は tmux の版で違う (Ubuntu 24.04 の tmux は既定を持たない。PLAN60)。
固定の値ではなく、空の設定を読ませた同じ tmux の既定と比べる。既定を持たない
tmux は値の無い ``terminal-overrides`` 行を出し、``_parse_options`` が空文字を
積むため、比べる前に空文字を除く (追記後の値には空文字が残らない)。
"""
empty_conf = tmp_path / "empty.tmux.conf"
empty_conf.write_text("")
defaults = [
d for d in _effective_options(empty_conf).get("terminal-overrides", []) if d
]
assert options["terminal-overrides"] == [*defaults, "xterm-256color:Tc"]


@needs_tmux
Expand Down
7 changes: 5 additions & 2 deletions tests/env/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging
import os
import stat
from pathlib import Path

import pytest

Expand Down Expand Up @@ -371,14 +372,16 @@ def test_undeletable_entries_fail_with_their_paths(openbao_root, openbao, monkey
bc.save(openbao_root, bc.BackendConfig(backend='openbao', openbao=ob,
cache_enabled=False))
target = cache.entry_path(openbao_root, GLOBAL)
real_unlink = os.unlink
real_unlink = Path.unlink

def deny(path, *a, **kw):
if str(path) == str(target):
raise PermissionError('denied')
return real_unlink(path, *a, **kw)

monkeypatch.setattr(os, 'unlink', deny)
# os.unlink ではなく Path.unlink を差し替える。Python 3.10 の pathlib は
# os.unlink をクラス定義の時点で束縛するため、os 側の差し替えが届かない (PLAN60)
monkeypatch.setattr(Path, 'unlink', deny)

with pytest.raises(SecretStoreError) as exc:
SecretStore(openbao_root).load(GLOBAL)
Expand Down
Loading