0
0

Pythonで〇×ゲームのAIを一から作成する その31 〇の勝利の判定に対するテスト

Last updated at Posted at 2023-11-26

目次と前回の記事

実装の進捗状況と前回までのおさらい

〇×ゲームの仕様と進捗状況

  1. 正方形で区切られた 3 x 3 の 2 次元のゲーム盤上でゲームを行う
  2. ゲーム開始時には、ゲーム盤のすべてのマスは空になっている
  3. 2 人のプレイヤーが遊ぶゲームであり、一人は 〇 を、もう一人は × のマークを受け持つ
  4. 2 人のプレイヤーは、交互に空いている好きなマスに自分のマークを 1 つ置く
  5. 先手は 〇 のプレイヤーである
  6. プレイヤーがマークを置いた結果、縦、横、斜めのいずれかの一直線の 3 マスに同じマークが並んだ場合、そのマークのプレイヤーの勝利とし、ゲームが終了する
  7. すべてのマスが埋まった時にゲームの決着がついていない場合は引き分けとする

仕様の進捗状況は、以下のように表記します。

  • 実装が完了した部分を 背景が灰色の長方形 で記述する
  • 実装の一部が完了した部分を、太字 で記述する

これまでに作成したモジュール

以下のリンクから、これまでに作成したモジュールを見ることができます。

test.py が扱うテストケースは、Excel 座標を "," で区切って連結したデータです。

前回までのおさらい

前回の記事までで、テストケースを記述するためのデータ構造やアルゴリズムについて説明しました。今回の記事では、中断していたテストの作業を再開し、バグを修正します。

テストに関するおさらい

長い間テストの作業を中断していたので、テストに関するおさらいをします。

以前の記事 で、勝敗判定 を行う下記の Marubatsu クラスの judge メソッド を実装した際に、judge メソッドに バグが存在する ことを説明しました。

def judge(self):
    # 判定を行う前に、決着がついていないことにしておく
    winner = None
    # 〇 の勝利の判定
    if self.board[0][0] == self.board[1][0] == self.board[2][0] == Marubatsu.CIRCLE or \
       self.board[0][1] == self.board[1][1] == self.board[2][1] == Marubatsu.CIRCLE or \
       self.board[0][2] == self.board[1][2] == self.board[2][2] == Marubatsu.CIRCLE or \
       self.board[0][0] == self.board[0][1] == self.board[0][2] == Marubatsu.CIRCLE or \
       self.board[1][0] == self.board[1][1] == self.board[1][2] == Marubatsu.CIRCLE or \
       self.board[2][0] == self.board[2][1] == self.board[2][2] == Marubatsu.CIRCLE or \
       self.board[0][0] == self.board[1][1] == self.board[2][2] == Marubatsu.CIRCLE or \
       self.board[2][0] == self.board[1][1] == self.board[0][2] == Marubatsu.CIRCLE:
        winner = Marubatsu.CIRCLE
        
    # × の勝利の判定
    if self.board[0][0] == self.board[1][0] == self.board[2][0] == Marubatsu.CROSS or \
       self.board[0][1] == self.board[1][1] == self.board[2][1] == Marubatsu.CROSS or \
       self.board[0][2] == self.board[1][2] == self.board[2][2] == Marubatsu.CROSS or \
       self.board[0][0] == self.board[0][1] == self.board[0][2] == Marubatsu.CROSS or \
       self.board[1][0] == self.board[1][1] == self.board[1][2] == Marubatsu.CROSS or \
       self.board[2][0] == self.board[2][1] == self.board[2][2] == Marubatsu.CROSS or \
       self.board[0][0] == self.board[1][1] == self.board[2][2] == Marubatsu.CROSS or \
       self.board[2][0] == self.board[1][1] == self.board[2][2] == Marubatsu.CROSS:
        winner = Marubatsu.CROSS     

    # 引き分けの判定
    if not(self.board[0][0] == Marubatsu.EMPTY or \
           self.board[1][0] == Marubatsu.EMPTY or \
           self.board[2][0] == Marubatsu.EMPTY or \
           self.board[0][1] == Marubatsu.EMPTY or \
           self.board[1][1] == Marubatsu.EMPTY or \
           self.board[1][1] == Marubatsu.EMPTY or \
           self.board[0][2] == Marubatsu.EMPTY or \
           self.board[1][2] == Marubatsu.EMPTY or \
           self.board[2][2] == Marubatsu.EMPTY):
        winner = Marubatsu.DRAW

    # winner を返り値として返す
    return winner

実装したプログラムが 正しく動作 することを 確認する作業 のことを テスト と呼び、judge メソッドのような、複雑な処理 を行う関数を 実装 した場合は、テストを行う ことが 重要 です。

以前の記事では、テストの手法 の一つである、制御フローテスト について説明し、制御フローテストには、下記の表のような、いくつかの 種類がある 事を説明しました。

次に、以前の記事 で、下記の表の中の 命令網羅(C0)、分岐網羅(C1)のテストを行いましたが、これらのテストでは test_judge の中にあるバグを 発見 することは できませんでした

名称 略称 テストケースの数 精度
命令網羅 C0、SC 最小 最小
分岐網羅 C1、DC
条件網羅 C2、CC 小~中
判定条件/条件網羅 CDC 小~中
複数条件網羅 MCC
改良条件判断網羅 MC/DC
経路組み合わせ網羅 なし 最大 最大

以前の記事で説明したように、本記事では条件網羅(C2)、判定条件/条件網羅(CDC)、複数条件網羅(MCC)は 行いません。今回の記事では残りのテストのうち、改良条件判断網羅 (MC/DC)のテストを開始します。

改良条件判断網羅(MC/DC)によるテスト

改良条件判断網羅(以後は MC/DC と表記します) では、以下のようなテストを行います。

条件分岐の 条件式の中 に、and または or の 論理演算子 が記述されている場合、それらで連結された それぞれの式の値 が、全体の条件式 の計算結果に 影響を及ぼす(因果関係がある)かどうかを 考慮 したテストを行う。

MC/DC のテストを行うために必要な テストケースを求める方法 は、以前の記事で説明したように、一般的 にはそれほど 簡単ではありません が、条件式or 演算子のみ連結 されている場合は、以前の記事で説明した下記の方法で 簡単に求める ことができます。

  • or 演算子連結 された の計算結果が すべて False になる テストケース
  • or 演算子連結 された の計算結果のうち、1 つだけTrue になる テストケース

judge メソッドには 〇 の勝利× の勝利引き分け判定 する 3 つの if 文 が記述されており、前者の 2 つ条件式or 演算子のみ連結 されているので、それら の if 文に対する テストケース簡単に求める ことが出来ます。引き分けの場合に関しては次回の記事で説明します。

〇 が勝利した場合のテストケース

まず、〇が勝利した場合 の下記の if 文に対する テストケースを求める ことにします。

    if self.board[0][0] == self.board[1][0] == self.board[2][0] == Marubatsu.CIRCLE or \
       self.board[0][1] == self.board[1][1] == self.board[2][1] == Marubatsu.CIRCLE or \
       self.board[0][2] == self.board[1][2] == self.board[2][2] == Marubatsu.CIRCLE or \
       self.board[0][0] == self.board[0][1] == self.board[0][2] == Marubatsu.CIRCLE or \
       self.board[1][0] == self.board[1][1] == self.board[1][2] == Marubatsu.CIRCLE or \
       self.board[2][0] == self.board[2][1] == self.board[2][2] == Marubatsu.CIRCLE or \
       self.board[0][0] == self.board[1][1] == self.board[2][2] == Marubatsu.CIRCLE or \
       self.board[2][0] == self.board[1][1] == self.board[0][2] == Marubatsu.CIRCLE:
        winner = Marubatsu.CIRCLE

上記の if 文の条件式は、8 つの式or 演算子連結 されており、それぞれの式 は、〇 が下図のように、直線上に 3 つ並んでいる かどうかを 判定 しています。

すべてが False になるテストケース

この条件式の中で、「or 演算子連結 された の計算結果が すべて False」になるテストケースとして 最も簡単 なものは、ゲーム盤に一つも マークが配置されていない 状態です。以前の記事で説明したように、テストケース は、「テストデータ と、期待される処理組み合わせたもの」なので、「ゲーム盤に一つもマークが配置されていない状態」で judge メソッドを実行した際に 期待される処理 である、judge メソッド返り値 が何になるかを 考える必要 があります。

ゲーム盤に一つもマークが配置されていない場合は、〇の勝利×の勝利引き分けいずれの場合でもない ので、judge メソッドの 3 つの if 文条件式 の計算結果は すべて False になります。従って、judge メソッドの最初でローカル変数 winner に代入 された None という値 は、変更されることは無い ので、judge メソッドの 返り値None になる ことが 期待されます

下図の 赤い線枠線 が、ゲーム盤に マークが配置されていない場合judge メソッドを実行した場合の 処理の流れ を表しており、図から、関数の返り値None になる ことがわかります。

本記事では、着手 を行う 複数の座標 を表す データ構造 として、以前の記事 で説明した Excel 座標"," で区切って 連結 するデータ構造を採用します。下記は、そのデータ構造でゲーム盤に一つもマークが配置されていない場合の テストケースを記述 したプログラムです。このテストケースでは、着手を行わない ので、着手を表すデータは 空の文字列 である "" になります。

from marubatsu import Marubatsu

testcases = [
    # ゲーム盤に一つもマークが配置されていない場合のテストケース
    [                     
        "",
        None, 
    ],           
]

テストの確認

このテストケースに対して test_judge でテストを行うと、以下のような エラーが発生 します。

from test import test_judge

test_judge(testcases)

実行結果

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
c:\Users\ys\ai\marubatsu\031\marubatsu.ipynb セル 2 line 3
      1 from test import test_judge
----> 3 test_judge(testcases)

File c:\Users\ys\ai\marubatsu\031\test.py:25, in test_judge(testcases)
     23 mb = Marubatsu()
     24 for coord in testdata.split(","):
---> 25     x, y = excel_to_xy(coord)            
     26     mb.move(x, y)
     27 print(mb)

File c:\Users\ys\ai\marubatsu\031\test.py:48, in excel_to_xy(coord)
     34 def excel_to_xy(coord: str) -> tuple(int):
     35     """ Excel 座標を xy 座標に変換する.
     36     
     37     "A1" のような、文字列で表現される Excel 座標を、
   (...)
     46         x 座標と y 座標を要素として持つ tuple
     47     """
---> 48     x, y = coord
     49     return "ABC".index(x), int(y) - 1

ValueError: not enough values to unpack (expected 2, got 0)

上記のエラーメッセージは、以下のような意味を持ちます。

  • ValueError
    値(value)に関するエラー
  • not enough values to unpack (expected 2, got 0)
    展開する(to unpack)ために必要な値(value)が 2 つ期待されている(expected)が、0 個(got 0)しかないため足りない(not enough)

エラーの原因の究明

エラーメッセージ から、excel_to_xy のブロック の中で x, y = coord の処理を行う際に エラーが発生 していることがわかります。また、この文は、coord に代入 された、2 文字の Excel 座標1 文字目x に、2 文字目y代入 する処理ですが、エラーメッセージ から、coord代入 されている 文字列の長さ0 である ことが 推測 されます。

エラーメッセージ から、excel_to_xy は、test_judge のブロックの、下記 のプログラムの 部分 から 呼び出されている ので、この部分について 調べてみる必要 がありそうです。

        for coord in testdata.split(","):
            x, y = excel_to_xy(coord) 

上記のプログラムでは、for 文の 反復可能オブジェクト として、testdata.split(",") が記述 されていますが、このテストケース の場合は、testdata には、空文字 である "" が代入 されているので、その状態 で、下記のプログラムのように、testdata.split(",") を実行 してみます。

testdata = ""
print(testdata.split(","))

実行結果

['']

実行結果からわかるように、testdata.split(",") によって、空文字要素 として持つ list が得られる ことがわかります。そのため、for coord in testdata.split(","): を実行すると、coord に空文字が代入 された状態で、for 文のブロックが実行 され、その 結果 として、excel_to_xy のブロックの中で x, y = "" が実行される ため、先ほどの エラーが発生 します。下記のプログラムによって、実際x, y = "" を実行し、同じエラーが発生 することを 確認 できます。

x, y = ""

実行結果

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
c:\Users\ys\ai\marubatsu\031\marubatsu.ipynb セル 4 line 1
----> 1 x, y = ""

ValueError: not enough values to unpack (expected 2, got 0)

split メソッドに関する注意点

先程のテストケースでは、一つも 着手を行わない ことを表す データ空文字で表現 しました。これは、着手するデータ を Excel 座標という 文字列"," で区切って表現 することと、着手 するデータが 存在しない ということから、人間にとって は、自然な表現方法 だと思います。

また、split メソッド は、特定の文字列区切られた 文字列を、list に変換 する処理を行うので、Excel 座標を "," で区切って連結 したデータを list に変換 する際に、split メソッドを利用 するのも 自然な考え方 だと思いますが、そこに今回のエラーの 落とし穴 があります。

この エラーの原因 は、空文字split メソッドlist に変換 すると、空の list に変換 されると 錯覚 してしまった点にあります。実際に、筆者もそのような錯覚 をしていたため、今回の記事を記述するまで、このような バグtest_judge存在 することに 気が付いていませんでした

人間は 先入観を持つ 生き物です。関数の説明の 概要から、勝手にその関数が行う処理を 理解したつもりになる ことは良くある事なので、その点に 注意する必要 があります。

上記のように、他人が作成した関数プログラム実際に行う処理 と、その関数やプログラムを 利用する人期待する処理微妙に異なる ことによって バグが発生 することは、よくある事 です。そのようなことを 避ける ためには、関数やプログラムの 使い方の説明よく読んで正確に理解 する必要があります。

可能であれば そうすべきですが、時間などの都合によって、自分が利用する すべての関数 の説明をしっかりと読んで、細部まで理解する ことが 現実的ではない 場合があります。例えば、これまでに紹介した print などの組み込み関数の説明を細部まで読んで すべて理解 している人は あまり多くない と思います。筆者も他人が作成した関数を利用する際に、それらのすべての関数の説明を熟読し、詳細まで理解して使っているわけではありません。

また、説明を 熟読したとしても説明 そのものが 間違っていたり、内容が 古かったりする1こともあります。従って、どれだけ注意しても、このようなバグが発生することを 完全に無くす ことは 現実的には無理 だと思いますので、バグが発生した後 で、バグの原因見つけ出して修正する という 技術が重要 になります。

何度も同じようなことを書いているので、本当にくどいと思っている方がいるかもしれませんが、そのような技術を身に付けるためには、そのような バグを体験 し、実際に修正 するという 経験を積む 以外の方法はないと思いますので、本記事ではそのようなバグが発生した場合に、なるべく裏でこっそりと修正した記事を載せるのではなく、正直にそのようなバグが発生したことを記述し、その修正方法まで載せることにしています。

このバグを修正する方法を説明する前に、空文字に対して split メソッドを実行した際に、空の list に変換されない 理由 について説明します。

例えば、"ABC".split(",") のように、文字列の中 に1 つも "," が記述されていない 場合に、split メソッドの 実引数に "," を記述 して list に変換 する処理を考えてみて下さい。この場合に、文字列の中に "," が記述されていないから といって、空の list に変換される と考える人は ほとんどいない のではないでしょうか?実際に、下記のプログラムのように、この処理によって、['ABC'] という、元の文字列要素 とする list に変換 されます。

print("ABC".split(","))

実行結果

['ABC']

空文字 のことを、特別な文字列 であると 考えている 人がいる かもしれません が、"ABC" も、空文字も、文字列である ことに 変わりはありません。従って、"".split(",") を実行すると、"ABC".split(",")同様 に、元の文字列要素 とする [''] という list に変換されます。

文字列型のデータの split メソッド は、以下の処理 を行う。

  • 文字列 を、split メソッドの 実引数 に記述した文字列で 区切り区切られた文字列要素 とする list に変換 する
  • 文字列の中 に、split メソッドの 実引数 に記述した 文字列が存在しない 場合は、元の文字列のみ要素 とする list に変換 する
  • split メソッドによって、空の list作成 される ことはない

エラーの修正

テストケースに記述した 空文字空の list変換したい 場合は、下記のプログラムのように、if 文 を使って、文字列空文字であるか どうかを チェックする 必要があります。下記のプログラムでは、list に変換 したデータを coords というローカル変数に 代入する ことにし、1 ~ 4 行目の if 文で、空文字の場合空の list を、そうでない場合 はこれまで通り、split メソッド を使って list に変換 したデータを coords に代入 しています。また、5 行目で coordsfor 文反復可能オブジェクト として 記述 するように修正しています。

1       if testdata == "":
2           coords = []
3       else:
4           coords = testdata.split(",")
5       for coord in coords:
6           x, y = excel_to_xy(coord) 
行番号のないプログラム
        if testdata == "":
            coords = []
        else:
            coords = testdata.split(",")
        for coord in coords:
            x, y = excel_to_xy(coord) 
修正箇所
+       if testdata == "":
+           coords = []
+       else:
+           coords = testdata.split(",")
-       for coord in testdata.split(","):
+       for coord in coords:
            x, y = excel_to_xy(coord) 

上記をもっと 簡潔に記述 したい人は、以前の記事 で説明した、三項演算子 を使って、下記のプログラムのように記述すると良いでしょう。本記事ではこのように記述することにします。

        coords = [] if testdata == "" else testdata.split(",")
        for coord in coords:
            x, y = excel_to_xy(coord) 
修正箇所
-       if testdata == "":
-           coords = []
-       else:
-           coords = testdata.split(",")
+       coords = [] if testdata == "" else testdata.split(",")
        for coord in coords:
            x, y = excel_to_xy(coord) 

coords を使わずに、下記のように さらに短く記述 することも可能ですが、プログラムが わかりにくくなる という欠点があるので、本記事では 採用しません

        for coord in [] if testdata == "" else testdata.split(","):
            x, y = excel_to_xy(coord) 
修正箇所
-       coords = [] if testdata == "" else testdata.split(",")
-       for coord in coords:
+       for coord in [] if testdata == "" else testdata.split(","):
            x, y = excel_to_xy(coord) 

下記のプログラムは、上記の修正を行った test_judge です。なお、この関数の中で excel_to_xy を使用しているので、1 行目でこの関数をインポートしています。

from test import excel_to_xy

def test_judge(testcases):
    for testcase in testcases:
        testdata, winner = testcase
        mb = Marubatsu()
        coords = [] if testdata == "" else testdata.split(",")
        for coord in coords:
            x, y = excel_to_xy(coord)            
            mb.move(x, y)
        print(mb)

        if mb.judge() == winner:
            print("ok")
        else:
            print("error!")
修正箇所
def test_judge(testcases):
    for testcase in testcases:
        testdata, winner = testcase
        mb = Marubatsu()
+       coords = [] if testdata == "" else testdata.split(",")
-       for coord in testdata.split(","):
+       for coord in coords:
            x, y = excel_to_xy(coord)            
            mb.move(x, y)
        print(mb)

        if mb.judge() == winner:
            print("ok")
        else:
            print("error!")

修正後の test_judge を使うことで、下記のプログラムのようにエラーが発生しなくなります。また、実行結果から、正しくテストを行うことができることが確認できます。

test_judge(testcases)

実行結果

Turn o
...
...
...

ok

None の判定と、is、is not 演算子

実は、この時点で気づいたのですが、test_judge にはあまりよくない記述があります。それは、test_judge の下記の部分で、== 演算子 を使って、mb.judge() の返り値 と、期待される返り値比較 を行っている点です。

        if mb.judge() == winner:
            print("ok")
        else:
            print("error!")

mb.judge() が、数値型 や、文字列型 のデータ のみを返す 場合は、== 演算子 を使った比較で 全く問題はない のですが、mb.judge() は、ゲームの 決着がついていない 場合は、None を返す ように定義しています。Python では、None== 演算子で比較 することは 推奨されていません

== 演算子と同値性

その理由は、== 演算子が、同じ意味 を持つ値であるかどうかを 判定する という処理が行われるからです。そのような性質を 同値性 と呼びます。

意味が分かりづらいと思いますので、具体例を挙げて説明します。これまで 数字 を表すデータの事を、区別する必要がない 場合は 数値型 のように、区別せず に呼んできましたが、Python では、0 のような 整数 のデータは 整数型(int)、1.2 のような 小数点を含む データは 浮動小数点数型(float)という、異なるデータ型 で表現されます。そのため、Python では、整数型浮動小数点数型 の、2 種類 のデータ型で 0 という数値を 表現 することができます。

Python では、数値に 小数点をつけて記述 することで、浮動小数点数型 のデータを表現するという 決まり になっているので、0 は整数型0.0 は浮動小数点数型 のデータです。そのことは、データ型 を返す、組み込み関数 type2 を使って下記のプログラムのように確認できます。

print(type(0))
print(type(0.0))

実行結果

<class 'int'>
<class 'float'>

Python では、00.0 は、異なるデータ型 のデータですが、その 意味同じゼロ です。先程説明したように、==同じ意味 のデータであるかどうかを表す 同値性を判定 する演算子なので、下記のプログラムのように 00.0== で比較 すると True になります。

print(0 == 0.0)

実行結果

True

異なるデータ型 のデータを == 演算子比較 した場合に 行われる処理 はデータ型によって異なります。そのため、「None」と、「None ではないが None と同じ意味 を持つデータ」を == 演算子比較 すると True になる 場合 があります。これが、None であるかどうか== で比較 することが __推奨されない理由__です。

なお、等しくない ことを 判定 する != 演算子 についても 同様の理由 で、None でない ことを 判定 するために使うことは 推奨されません

is 演算子と同一性

データが 完全に同じ ものであることを、同一性 と呼び、Python では、is 演算子 によって 前後のデータ同一 であるかどうかを 判定 することができます。

下記のプログラムのように、00.0 は、同じ意味を持ちますが、異なるデータ なので、is 演算子 を使って 比較 すると、計算結果が False になります。

a = 0
b = 0.0
print(a == b)
print(a is b)

実行結果

True
False

同様 の演算子に、同一でない ことを判定する演算子として is not という 演算子 があります。is not一つの演算子 である点に注意して下さい。

is 演算子の注意点

Python の is 演算子 は、データを管理する オブジェクトが等しい かどうかによって 同一性判定 します。従って、以前の記事 で説明したように、全く同じ数値 データであっても、オブジェクトの id異なる ような場合に、is 演算子で比較 を行うと、下記のプログラムのように、そのデータを管理する オブジェクトが異なる という理由で False になる場合 があります。従って、is 演算子数値型 や、文字列型 どうしの 比較 を行う際に 使うべきではありません

a = 1.23
b = 1.23
print(a is b)

実行結果(OS や Python のバージョンによっては True になる場合があります)

False

初心者のうちは、is 演算子 は、None であるかどうかを判定 する場合 のみで使う と覚えておき、必要になってから他の is 演算子の用途について学べば十分だと思います。

プログラムの修正

先程のプログラムの修正方法の一つは、下記のプログラムのように、テストケースの 期待される値None の場合 は、is 演算子 を、それ以外の場合== 演算子 を使って比較を行います。

        if winner is None:
            if mb.judge() is winner:
                print("ok")
            else:
                print("error!")
        else:
            if mb.judge() == winner:
                print("ok")
            else:
                print("error!")           

上記のプログラムは、無駄が多い ので、and と or 演算子 を使って下記のように記述することができます。ただし、何れの方法を使っても、判定の処理が複雑になる ことには変わりはありません。

        if (winner is None and mb.judge() is winner) or \
           (winner is not None and mb.judge() == winner):
            print("ok")
        else:
            print("error!")

別の方法 として、決着がついていない場合 に、judge メソッドの 返り値 として、None 以外 のデータを 返す ように修正するという方法があります。この方法であれば、test_judge を変更 する 必要はなくなる ので、本記事では、その方法を採用することにします。

この方法を採用する場合は、決着がついていない場合 に、judge メソッドの 返り値何を返すか決める 必要があります。決着がついていない場合を 除くとjudge メソッドが 返すデータ は、下記のプログラムのように、Marubatsu クラスの先頭に、クラス属性として定義 していました。

class Marubatsu:
    EMPTY = "."
    CIRCLE = "o"
    CROSS = "x"
    DRAW = "draw"

そこで、決着がついていない場合 を表す値も 同様 に、クラス属性 として 定義 する事にします。「決着がついていない」ということは、「ゲームをプレイ中」であると 言い換える ことが出来るので、クラス属性の名前を PLAYING という名前にすることにします。また、その値 は、他のクラス属性と 区別 できれば なんでも構わない ので、DRAW と同様に、"playing" という 文字列で表現 することにします。そこで、下記のプログラムのように、Marubatsu クラスクラス属性 に、PLAYING を追加 します。

Marubatsu.PLAYING = "playing"

次に、テストケース を下記のように 修正 します。

testcases = [
    # ゲーム盤に一つもマークが配置されていない場合のテストケース
    [                     
        "",
        Marubatsu.PLAYING, 
    ],           
]

修正した test_judge を使ってテストを実行すると、筆者もうまくいくと思っていたのですが、実行結果に error! が表示 されてしまいます。その理由について少し考えてみて下さい。

test_judge(testcases)

実行結果

Turn o
...
...
...

error!

error! が表示 されるのは、test_judge のブロックの 下記の部分 です。従って、mb.judge() の返り値 か、winner の値いずれか 、または その両方 が間違っている 可能性が高い でしょう。

            if mb.judge() == winner:
                print("ok")
            else:
                print("error!")  

これまでのエラー の多くは、プログラムが停止 するようなエラーで、その際に表示される エラーメッセージ は、Python が作成 した 詳細 なもので、それを頼り にエラーを 修正 してきました。

一方、今回のエラー は、プログラムが停止 するようなエラー ではなく、表示される エラーメッセージ は、自分で何を表示するかprint で記述 することで表示されたものです。このエラーメッセージは、エラーがあることしか説明 していないので、このメッセージから mb.judge()winnerどちらが間違っているか推測 することは できません。この 問題 は、エラーの メッセージ に、エラーの 原因を特定 できるような 情報が含まれていない ことが原因です。従って、下記のプログラムのように、エラーの原因 となる、mb.judge()winner の値エラーメッセージに含める ように修正することで、メッセージから エラーの原因推測しやすく なります。

なお、下記のプログラムで表示される エラーメッセージ一例 です。これより詳しいエラーメッセージを表示したい場合は、print で表示する内容を 自由に修正 して下さい。

def test_judge(testcases):
    for testcase in testcases:
        testdata, winner = testcase
        mb = Marubatsu()
        for coord in [] if testdata == "" else testdata.split(","):
            x, y = excel_to_xy(coord)            
            mb.move(x, y)
        print(mb)

        if mb.judge() == winner:
            print("ok")
        else:
            print("test_judge error!")
            print("mb.judge():", mb.judge())
            print("winner:    ", winner)
修正箇所
def test_judge(testcases):
    for testcase in testcases:
        testdata, winner = testcase
        mb = Marubatsu()
        for coord in [] if testdata == "" else testdata.split(","):
            x, y = excel_to_xy(coord)            
            mb.move(x, y)
        print(mb)

        if mb.judge() == winner:
            print("ok")
        else:
-           print("error!")
+           print("test_judge error!")
+           print("mb.judge():", mb.judge())
+           print("winner:    ", winner)

修正後に test_judge を実行すると、以下のような実行結果が表示されます。

test_judge(testcases)

実行結果

Turn o
...
...
...

test_judge error!
mb.judge(): None
winner:     playing

実行結果から、期待される値 である winner には 正しい "playing"代入 されていますが、mb.judge() の返り値None のまま間違っている ことがわかります。原因 が、judge メソッドにある ことがわかったので、関数の定義調べてみる、下記のプログラムのように、最初に winnerNone を代入したまま であることがわかります。

    def judge(self):
        # 判定を行う前に、決着がついていないことにしておく
        winner = None

    

従って、このバグは、下記のプログラムのように、judge メソッドの最初で、winnerMarubatsu.PLAYING を代入 するようにすることで修正することが出来ます。なお、長くなるので下記には judge メソッドの先頭の一部のみ記述しますが、github にアップロードする marubatsu.ipynb のほうでは、全体を記述します。

def judge(self):
    # 判定を行う前に、決着がついていないことにしておく
    winner = Marubatsu.PLAYING

    

Marubatsu.judge = judge   
修正箇所
def judge(self):
    # 判定を行う前に、決着がついていないことにしておく
-   winner = None
+   winner = Marubatsu.PLAYING

下記のプログラムを実行することで、修正した test_judge が正しく動作することが確認できます。実行結果は先程と同様になるので省略します。

test_judge(testcases)

決着がついていないことを表すデータを、None から、Marubatsu.PLAYING に修正したことで、judge メソッドのローカル変数 winner の名前 と、その変数に 代入するデータ整合性がとれなくなります。具体的には、勝者を表す winner という名前の変数に、Marubatsu.PLAYING という、ゲームの 勝者無関係 なデータが代入される 可能性が生じます。このような場合は、winner という変数の名前を別のより ふさわしい名前に変更 することも考えられますが、この問題は 些細な問題 であると考えて、変数の名前を 修正しない という方法も考えられます。本記事では、このままでもプログラムの意味が著しくわかりにくくなることは無いと思いましたので、修正しないことにします。

1 つだけが True になるテストケース

次に、or 演算子連結 された の中の 1 つだけTrue になるテストケースを考えます。

〇の勝利を判定する if 文の条件式の中の、or 演算子で連結された それぞれ式が True になるのは、下図 のマスに 〇 が配置された場合 です。下図の 8 通りいずれの場合 でも、同時2 つ以上 の場所で 〇 が 3 つ並ぶことはない ので、1 つだけTrue になる ことが 保証 されます。従って、下図 のように 〇 が配置 された、8 つ のテストケースを 用意すれば良い ことがわかります。

次に、その 8 つのテストケース に対する judge メソッド期待される返り値 について考える必要があります。〇 を 3 つ配置 するということは、その間× は 2 回 しか 配置 しないことになるので、用意するテストケースで どこに × を配置 しても、× が勝利 することは ありません 。従って、× の勝利を判定 する if 文の 条件式 の計算結果は 必ず False になります。

テストケースでは、着手〇 の 3 回× の 2 回 の、計 5 回 になるので、引き分け になることは ありません。従って、引き分けを判定 する if 文の 条件式 の計算結果も 必ず False になります。

上記の事から、× は 空いているマスであれば、どのマスに配置しても良い こともわかります。

下図は、上記の テストケース の一つに対する judge メソッド処理の流れ を表す フローチャート で、赤い枠線と線 が行われる 処理の流れ を表します。図から、winner に関する 代入処理 は、赤字winner = Marubatsu.CIRCLE最後に行われる ので、judge メソッドの 期待される返り値Marubatsu.CIRCLE であることがわかります。

下記のプログラムは、先程のテストケースに、1 つだけTrue になる テストケースを 3 つ加えた ものです。8 つすべてを加えない理由についてはこの後で説明します。

なお、これまで はそれぞれのテストケースの 盤面コメントで記述 していましたが、テストケースを 数多く入力 際に、そのような コメント を毎回 記述するのは大変 なので以降は記述しません。

testcases = [
    # ゲーム盤に一つもマークが配置されていない場合のテストケース
    [                     
        "",
        Marubatsu.PLAYING, 
    ],           
    # 〇の勝利のテストケース
    [
        "A1,A2,B1,B2,C1",
        Marubatsu.CIRCLE
    ],
    [
        "A2,A1,B2,B1,C2",
        Marubatsu.CIRCLE
    ],
    [
        "A3,A1,B3,B1,C3",
        Marubatsu.CIRCLE
    ],
]

このテストケースで test_judge でテストを行うと以下のような実行結果が表示されます。

test_judge(testcases)

実行結果

Turn o
...
...
...

ok
Turn x
ooo
xx.
...

ok
Turn x
xx.
ooo
...

ok
Turn x
xx.
...
ooo

ok

上記のテストデータを実際に入力する際に、入力した座標正しいか どうかが 自信が持てない 人が多いのではないかと思います。筆者も 入力する際に間違った入力をしていることに気づいて 何度か修正 しました。また、入力したデータ正しいか どうかの 確認 を、入力したデータ そのもの目で見て確認 することは、Excel 座標にかなり慣れないと 困難です

test_judge が行う 処理の中 で、テストケースのデータを元に着手が行われた ゲーム盤を画面に表示 するようにしたのは、入力 した テストケース正しいか どうかを、わかりやすく 確認できるようにする ためです。テストケースのデータを入力し、test_judge を実行した際は、必ず実行結果を見て確認するようにして下さい。

上記の実行結果で表示される ゲーム盤の表示内容 から、意図通り のテストケースが 記述されている ことが 確認 できます。また、すべて のテストケースで ok が表示 されているので、入力したテストケース に対して judge メソッド期待された処理 を行うことが 確認 できます。

test_judge は、judge メソッド期待される処理を行う かどうかを 確認 する だけでなく入力したテストケース正しい かどうかを 確認 する 役割 を持つ。

期待される返り値ごとに、テストケースをまとめる方法

先程のテストケースに、8 つ のテストケースを すべて追加しなかった理由 について説明します。

下記の先程のテストデータをよく見て下さい。無駄な記述 があると思う人はいないでしょうか?

testcases = [
    # ゲーム盤に一つもマークが配置されていない場合のテストケース
    [                     
        "",
        Marubatsu.PLAYING, 
    ],           
    # 〇の勝利のテストケース
    [
        "A1,A2,B1,B2,C1",
        Marubatsu.CIRCLE
    ],
    [
        "A2,A1,B2,B1,C2",
        Marubatsu.CIRCLE
    ],
    [
        "A3,A1,B3,B1,C3",
        Marubatsu.CIRCLE
    ],
]

上記のテストケースのデータ構造には、それほど大きな欠点とは言えないかもしれませんが、いくつかの問題があるため、改良の余地 があります。

問題点の一つは、3 つ の 〇 が勝利するテストケースの それぞれ に、Marubatsu.CIRCLE が記述 されている点です。これは、今後 〇 が勝利 する場合のテストケースを 記述するたび に、Marubatsu.CIRCLE を記述 する 必要がある ということを意味しますが、冗長 だと思いませんか?

もう一つの問題点は、異なる judge メソッドの 期待される返り値 を持つ テストケース が、1 つの listまとめられている という点です。現時点 では、〇 の勝利 のテストケースを 表す list の 要素隣り合って まとまっていますが、今後 この list に、他の期待される返り値 を持つテストケースを 追加 した際に、同じ期待される返り値 を持つテストケースが、list の中で バラバラに配置 されてしまう 可能性が高く なります。もちろん、バラバラにならないように気をつけながらデータを記述することもできますが、いちいちそのようなことを 注意しながら データを 記述 するのは 面倒 ですし、間違って バラバラになるように 記述 してしまった 場合 に、それをバラバラにならないように 記述し直す のは 大変 です。

judge メソッドをテストするテストケースには、4 種類期待される返り値 が存在します。また、その 4 種類の 期待される返り値対してそれぞれ複数 のテストケースを 記述 することができます。このことを、別の言葉で表現すると、1 つ期待される返り値 から、複数のテストデータ への 対応づけ が行われることを意味します。また、期待される返り値文字列 で表現されるので、この 対応づけ は、dict のキー期待される返り値 を、その キーの値対応 する 複数テストデータ要素 とする list代入 するという データ構造 で表現することができます。

具体的には、下記のプログラムのようにテストケースを記述することができます。なお、データが list から dict に変わった ので、データを 囲う記号[] から {} に修正 する必要があります。

testcases = {
    # ゲーム盤に一つもマークが配置されていない場合のテストケース
    Marubatsu.PLAYING: [
        "",
    ],   
    # 〇の勝利のテストケース
    Marubatsu.CIRCLE: [
        "A1,A2,B1,B2,C1",
        "A2,A1,B2,B1,C2",
        "A3,A1,B3,B1,C3",
    ],
}

このデータ構造は、先程のデータ構造と異なり、期待される返り値 を表すデータを dict のキー として 1 箇所だけに記述 するので、その分だけ 記述が簡潔 になります。

また、同じ 期待される返り値に 対応するテストデータ が、同じ list の中に 集まっている ので、それぞれの テストデータの意味わかりやすくなります

さらに、テストケース の全体の 記述の行数 も、大幅に短く することができます。

このように、新しいデータ構造には様々な利点があるので、本記事では以降はこのデータ構造でテストケースを記述することにします。

本記事では、以降はテストケースのデータ構造を以下のように記述する。

  • dict で表現する
  • dict の キー は、judge メソッド期待される返り値 を表す
  • キーの値 には、キーの値に 対応する テストデータを 要素とする list代入 する

テストケースを、list を使わず に、下記のように記述すれば良いと思った人はいないでしょうか?残念ながら、下記のプログラムのように、dict のデータを 記述 する際に、同じキー何度も記述 すると、実行結果からわかるように、最後に記述 された キーの値のみ代入 されることになります。なお、下記のプログラムでは、先程 testcases に代入したデータを壊さないようにするために、testcases2 という変数に値を代入しています。

testcases2 = {
    # ゲーム盤に一つもマークが配置されていない場合のテストケース
    Marubatsu.PLAYING: "",
    # 〇の勝利のテストケース
    Marubatsu.CIRCLE:  "A1,A2,B1,B2,C1",
    Marubatsu.CIRCLE:  "A2,A1,B2,B1,C2",
    Marubatsu.CIRCLE:  "A3,A1,B3,B1,C3",
}

print(testcases2)

実行結果

{'playing': '', 'o': 'A3,A1,B3,B1,C3'}

上記のような処理が行われる 理由 は、上記のプログラムを実行すると、下記のプログラムのような処理が行われるからです。dict のデータを記述して実行すると、下記のプログラムのように、空の dict最初に作成 され、その後で、記述された順番通り に、dict のキー値が代入 されます。従って、上記のプログラムは、下記のように、同じキー に対する 代入処理何度も行われ、そのたびに キーの値上書き されることになります。

testcases2 = {}
testcases2[Marubatsu.PLAYING] = ""
testcases2[Marubatsu.CIRCLE] = "A1,A2,B1,B2,C1"
testcases2[Marubatsu.CIRCLE] = "A2,A1,B2,B1,C2"
testcases2[Marubatsu.CIRCLE] = "A3,A1,B3,B1,C3"

print(testcases2)

実行結果

{'playing': '', 'o': 'A3,A1,B3,B1,C3'}

test_judge の修正

テストケースの データ構造が変わった ので、それに合わせて test_judge を修正 する必要があります。test_judge に対する修正は以下の通りです。

  • 2 行目を for winner, testdata_list in testcases.items(): に修正する
  • 元のプログラムにあった testdata, winner = testcase を削除 する
  • 3 行目を、for testdata in test_data_list: に修正し、残り のプログラムの インデントを右にずらす ことで、この for 文のブロックプログラムになる ように修正する

2 行目では、以前の記事 で説明した、for 文で dict の キーと値の両方を取り出す繰り返し を記述しています。testcases のキー は、judge メソッドの 期待される返り値 を表すデータです。従って、このデータを winner代入 することで、修正前と同じ処理 を行うことができます。また、この修正により、元の testdata, winner = testcase必要が無くなった ので 削除 しています。

testcasesキーの値 は、キーに対応 する テストデータの list なので、その値を testdata_list という名前の 変数に代入 しています。そして、3 行目の for 文で、testdata_list先頭の要素 から 順番に取り出しtestdata に代入 するという 繰り返し処理 を記述することで、2 行目で testcases から取り出した、winner に対応 するテストデータの テストまとめて行う ことができます。なお、3 行目以降のプログラムは、修正する前judge メソッドのプログラムと、インデントが変わった点を除けば、同一の内容 になります。

 1  def test_judge(testcases):
 2      for winner, testdata_list in testcases.items():
 3          for testdata in testdata_list:
 4              mb = Marubatsu()
 5              for coord in [] if testdata == "" else testdata.split(","):
 6                  x, y = excel_to_xy(coord)            
 7                  mb.move(x, y)
 8              print(mb)
 9
10              if mb.judge() == winner:
11                  print("ok")
12              else:
13                  print("test_judge error!")
14                  print("mb.judge():", mb.judge())
15                  print("winner:    ", winner)
行番号のないプログラム
def test_judge(testcases):
    for winner, testdata_list in testcases.items():
        for testdata in testdata_list:
            mb = Marubatsu()
            for coord in [] if testdata == "" else testdata.split(","):
                x, y = excel_to_xy(coord)            
                mb.move(x, y)
            print(mb)

            if mb.judge() == winner:
                print("ok")
            else:
                print("test_judge error!")
                print("mb.judge():", mb.judge())
                print("winner:    ", winner)
修正箇所

4 行目以降 の for 文のブロックには インデントを追加 していますが、それらの行の修正を示すと、かえってわかりづらく 3 行目以降の修正は示しません。

def test_judge(testcases):
-   for testcase in testcases:
+   for winner, testdata_list in testcases.items():
-       testdata, winner = testcase
+       for testdata in testdata_list:
            mb = Marubatsu()
            for coord in [] if testdata == "" else testdata.split(","):
                x, y = excel_to_xy(coord)            
                mb.move(x, y)
            print(mb)

            if mb.judge() == winner:
                print("ok")
            else:
                print("test_judge error!")
                print("mb.judge():", mb.judge())
                print("winner:    ", winner)

下記のプログラムを実行することで、修正した test_judge が正しく動作することが確認できます。実行結果は先程と同様になるので省略します。

test_judge(testcases)

残りのテストケースの記述

下記は、新しいデータ構造 で、〇 が勝利 した場合の テストケースを記述 したプログラムです。

testcases = {
    # ゲーム盤に一つもマークが配置されていない場合のテストケース
    Marubatsu.PLAYING: [
        "",
    ],   
    # 〇の勝利のテストケース
    Marubatsu.CIRCLE: [
        "A1,A2,B1,B2,C1",
        "A2,A1,B2,B1,C2",
        "A3,A1,B3,B1,C3",
        "A1,B1,A2,B2,A3",
        "B1,A1,B2,A2,B3",
        "C1,A1,C2,A2,C3",
        "A1,A2,B2,A3,C3",
        "A3,A1,B2,A2,C1",
    ],
}

下記のプログラムを実行することで、修正した test_judge が正しく動作することが確認できます。実行結果を見て正しいマス にマークが 配置 されていることを 必ず確認 して下さい。ちなみに筆者は、確認した結果、一度間違っていることが判明したので修正しました。

test_judge(testcases)

実行結果

Turn o
...
...
...

ok
Turn x
ooo
xx.
...

ok
Turn x
xx.
ooo
...

ok
Turn x
xx.
...
ooo

ok
Turn x
ox.
ox.
o..

ok
Turn x
xo.
xo.
.o.

ok
Turn x
x.o
x.o
..o

ok
Turn x
o..
xo.
x.o

ok
Turn x
x.o
xo.
o..

ok

今回の記事のまとめ

今回の記事では、MC/DC テスト の中で、〇 の勝利を判定 する if 文 に対する テストケースを記述 し、テストを行いました。その際に、テストケース を表す データ構造改良 を行いました。

もちろん、MC/DC テストに限らず、完璧なテスト を行うことは 不可能 なので、バグが絶対に存在しない ことまでは 保証できません が、judge メソッドの中で、〇の勝利を判定 する if 文 に関しては、バグが存在する 可能性が低い ということは言えます。

本当は今回の記事でテストを終わらせる予定だったのですが、筆者も気づいていなかった split メソッドに関するバグが判明した点や、引き分けの場合の None に関する judge メソッドの返り値の問題点など、想定外の説明を行う必要がでたため、テストの残りは次回に続くことにします。

本記事で入力したプログラム

以下のリンクから、本記事で入力して実行した JupyterLab のファイルを見ることができます。

以下のリンクは、今回の記事で更新した marubatsu.py です。

以下のリンクは、今回の記事で更新した test.py です。

次回の記事

  1. 例えば、関数を 更新した後 で、その関数の 説明の更新を忘れる ことは 良くある ことです

  2. type については、前回の記事 を参照して下さい

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0