paiza用ローカルテスト環境の紹介 (Python)

[プログラミングゲームの解答コードを投稿しよう！](https://qiita.com/official-events/efe957f155af900604b3) の関連記事です．

https://ppg.paiza.jp/denno2088/

難しいプログラミング問題に取り組むとき，手元での確認・検証は重要です．
この記事では，自分のpaizaプログラミング用のローカルテスト環境を紹介します．
プログラム言語はPythonです．[Rank D問題：カジノ](https://ppg.paiza.jp/denno2088/challenges/denno2088_c_d_02)を例として取り上げます．
<br>
2行での要約：
[test_main.py](https://github.com/tomtkg/Python-Sample/blob/main/%E9%9B%BB%E8%84%B3%E5%B0%91%E5%A5%B3%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B02088/test_main.py) の`input1`，`output1`，`input2`，`output2`，`def main()`の中身を書き換えて，
ローカル環境でテストやデバッグ実行しよう．


# 出題・解答 環境
最初に，出題・解答 環境を確認します．
電脳少女はpaizaと画面構成など一部異なりますが，以下の要素を共通して持ちます．
* 問題の概要
* 入力される値
* 期待する出力
* 条件
* 入力例1
* 出力例1
* 入力例2
* 出力例2

また，Python3の「解答コード入力欄」は，最初に以下が書かれています．
```Python: 解答コード入力欄
# coding: utf-8
# 自分の得意な言語で
# Let's チャレンジ！！
input_line = input()
print("XXXXXX")
```
そして，「提出前動作確認」ボタンを押すと，アウトプットが `XXXXXX` だと確認できます．
提出前動作確認では，標準入力に対して「入力例1」が与えられ，「出力例1」を標準出力する事が期待されています．自由な入出力での動作確認（テスト）はできません．

この標準入力`stdin`と標準出力`stdout`をどう扱うかが，ローカルテストの鍵となります．

# オンラインテスト環境
オンラインテスト環境は，[paiza.IO](https://paiza.io/ja)を使うのがベストでしょう．
他にもいろいろなサービスがありますが，公式が[paiza.IO](https://paiza.io/ja)を推奨しています．
標準入力と標準出力は，ブラウザの後ろ側で良い感じに対応されています．

# ローカルテスト環境
## 準備
まず，Pythonが既に実行可能であることを前提とします．
ローカルテストには`pytest`を利用します．
`pip install pytest`で`pytest`がインストールできます．
必要に応じて以下のコマンドを実行し，`pytest`のバージョンが表示されることを確認してください．
```
$ python --version
Python 3.12.4
$ pytest --version
pytest 8.3.4
$ python -m pytest --version
pytest 8.3.4
```
次に，テストファイル [test_main.py](https://github.com/tomtkg/Python-Sample/blob/main/%E9%9B%BB%E8%84%B3%E5%B0%91%E5%A5%B3%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B02088/test_main.py) を用意します．
```python: test_main.py
import pytest


input1 = """
入力例1
""".strip()

output1 = """
出力例1
""".strip()

input2 = """
入力例2
""".strip()

output2 = """
出力例1
""".strip()


def main():
    解答


# 以下は固定
def test_main(monkeypatch) -> None:
    check(monkeypatch, main, input1, output1)
    check(monkeypatch, main, input2, output2)


def check(monkeypatch, func: None, input: str, output: str) -> None:
    import io

    stdin = io.StringIO(input)
    stdout = io.StringIO()

    with monkeypatch.context() as m:
        m.setattr("sys.stdin", stdin)
        m.setattr("sys.stdout", stdout)
        func()

    assert stdout.getvalue() == output + "\n"


if __name__ == "__main__":
    pytest.main([__file__])
```

このファイルは，すべての問題で使いまわします．
問題毎にファイルの中身を書き換えて，ローカルテストを実行します．

## 使い方
準備したテストファイル [test_main.py](https://github.com/tomtkg/Python-Sample/blob/main/%E9%9B%BB%E8%84%B3%E5%B0%91%E5%A5%B3%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B02088/test_main.py) の日本語部分を適切なものに書き換えます．
[Rank D問題：カジノ](https://ppg.paiza.jp/denno2088/challenges/denno2088_c_d_02)の場合，ファイルを以下のように書き換えます．

```diff_python: test_main.py
import pytest


input1 = """
- 入力例1
+ 1
+ 5
+ 2
""".strip()

output1 = """
- 出力例1
+ 46
""".strip()

input2 = """
- 入力例2
+ 8
+ 2
+ 80
""".strip()

output2 = """
- 出力例1
+ 818
""".strip()


def main():
-   解答
+   print(sum(i * int(input()) for i in [1, 5, 10]))


# 以下は固定
～～～省略～～～
```

そして，以下のどちらかのコマンドでテストを実行します．
```sh
pytest test_main.py
or
python test_main.py
```
緑の文字で`1 passed in 0.02s`のように表示されたら，テストは成功です．
赤の文字で`1 failed in 0.15s`のように表示されたら，テストは失敗です．
その場合，`short test summary info`など，失敗に関する情報が表示されます．

実際の使い方としては，まず最初に問題の入出力例をブラウザからコピペします．
そして，解答を`main`関数の中に書きます．解答の試行錯誤は，`main`関数内で実施します．
オンライン環境より実行速度が速いので，頻繁にテストを実行しても問題ありません．
解答がテストを通ったら，それをブラウザに張り付けて，提出します．

自分の場合，ブラウザでは「解答コード入力欄」も含め，文字のタイピングは一切行いません．
コピーとペーストだけ実施し，タイピングは [test_main.py](https://github.com/tomtkg/Python-Sample/blob/main/%E9%9B%BB%E8%84%B3%E5%B0%91%E5%A5%B3%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B02088/test_main.py) に対してのみ実施しています．

##  [test_main.py](https://github.com/tomtkg/Python-Sample/blob/main/%E9%9B%BB%E8%84%B3%E5%B0%91%E5%A5%B3%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B02088/test_main.py) の解説
ここからは，[test_main.py](https://github.com/tomtkg/Python-Sample/blob/main/%E9%9B%BB%E8%84%B3%E5%B0%91%E5%A5%B3%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B02088/test_main.py) を解説します．

 [test_main.py](https://github.com/tomtkg/Python-Sample/blob/main/%E9%9B%BB%E8%84%B3%E5%B0%91%E5%A5%B3%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B02088/test_main.py) は，以下の設計方針，自身の要望に基づいて作成されています．
* すべてを1ファイルで完結させる
* 編集箇所をなるべく近くでまとめる
* ローカルテスト環境と本番のブラウザとのやり取りは，コピペのみで済ませる
* 入出力例に対する改変を不要にする（タブ，インデント，改行，[]の有無など）
* 入出力例1，2を2箇所以上に書かない

この設計に基づき， [test_main.py](https://github.com/tomtkg/Python-Sample/blob/main/%E9%9B%BB%E8%84%B3%E5%B0%91%E5%A5%B3%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B02088/test_main.py) の書き換え部分は，現在のかたちになりました．

すべてを1ファイルで完結させるとすると，入力例と出力例は，変数にする必要があります．
複数行の入力例をそのままコピペするには，以下の書き方がベストでした．

```python:
input1 = """
入力例1
""".strip()
```

入力例と出力例の間に3行が挟まりますが，これは許容範囲としました．

試行錯誤が必要な解答は，解答を`main`関数内で書くことにしました．
余分なインデントが入りますが，コピペで解答を提出できたため，良しとしました．

変数`input1`，`output1`，`input2`，`output2`と`main`関数ができたら，これらを結びつける処理が必要です．
最終的に，`pytest`の`monkeypatch`を利用するのがベストだと判断しました．
`monkeypatch`を使うと，`sys.stdin`や`sys.stdout`を差し替えることが出来ます．

テストの核心部分は，`check`関数として汎用的にまとめました．
```python:
def check(monkeypatch, func: None, input: str, output: str) -> None:
    import io

    stdin = io.StringIO(input)
    stdout = io.StringIO()

    with monkeypatch.context() as m:
        m.setattr("sys.stdin", stdin)
        m.setattr("sys.stdout", stdout)
        func()

    assert stdout.getvalue() == output + "\n"
```
`check`関数は，`monkeypatch`，関数`func`，入力例`input`，出力例`output`が入力になります．
出力はありませんが，内部でテストを実施します．
内部では，最初に`io`モジュールをインポートして，代替の`stdin`と`stdout`を作成します．
`stdin`には，`input`をセットします．
そして，`monkeypatch.context()`を利用し，`sys.stdin`と`sys.stdout`をそれぞれ`stdin`と`stdout`に置き換え，`func`を実行します．
最後に，`func`の標準出力を`stdout.getvalue()`で受け取り，それが`output`と一致するかテストします．

`test_main`関数は，`check`関数へ渡す関数と変数を制御します．
```python:
def test_main(monkeypatch) -> None:
    check(monkeypatch, main, input1, output1)
    check(monkeypatch, main, input2, output2)
```

以下は，常に`pytest`経由で`main`関数を呼び出すための設定です．
```python:
if __name__ == "__main__":
    pytest.main([__file__])
```
2行目を`main()`に変えると，直接`main`関数を呼び出すようになります．
変更後に`python test_main.py`を実行すると，多くの場合に標準入力待ちとなります．

## VS Code
ローカルテスト環境の一部として，エディタも紹介します．
自分はエディタとしてVS Codeを使っており，デバッグ実行(F5)をよく利用しています．
ブレークポイントを設定することで，プログラムの途中経過がわかり，とても便利です．
DEBUG CONSOLEは，変数の中身を表示可能で，簡単な計算も実行できるため，これも便利です．
VS Codeなら，以下の表示が可能です．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308259/240bcf46-5e2e-ad3d-a7db-6393a08180b7.png)


# 終わりに
自分のpaiza用ローカルテスト環境を紹介しました．
オンラインのコーディング環境も便利になってきていますが，まだ，ローカル環境の方が便利だと考えています．
是非， [test_main.py](https://github.com/tomtkg/Python-Sample/blob/main/%E9%9B%BB%E8%84%B3%E5%B0%91%E5%A5%B3%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B02088/test_main.py) をコピーして使ってみてください．