Photo by Mark Hawkinsこんにちは。倉内です。
皆さんはプログラミング問題を解くときに自分が書いたコードをテストしていますか?
paizaラーニングの演習課題やスキルチェック問題ではあらかじめ用意されたテストケースをWebブラウザ上でボタンを押すだけで実行できるため、テストコードを書かなくても出力結果の誤りに気づくことはできます。
しかし、用意されたテストケース以外の入力値を試したいときや、バグがある箇所を特定したいときなどは自分でテストコードを書く必要があります。
テストコードはいろいろな書き方ができますが、Pythonではdoctestという標準モジュールを使って簡易的なユニットテストを実施できます。
一見難しく感じるかもしれませんが、基本から順に説明していきますので一緒に習得していきましょう!
オンライン実行環境で試しながら学ぼう
このあとサンプルコードが出てきますが、できれば眺めるだけではなく実行したり自分で一部書き換えたりしたほうが理解が深まります。
とはいえ今からPythonの実行環境を自分のPCに準備するのは大変なので、ブラウザ上でコードを書いて即実行できるpaiza.IOを利用してみてください。
doctestを使ってみよう
◆doctestとは
doctestはdoc+testという名称からも分かるとおり、ドキュメントとして書かれたコメントを使ってテストをするためのモジュールです。
Pythonでは関数やクラスの先頭に文字列を記述するとdocstring(ドキュメンテーション文字列)として扱われます。用途としては「どんな処理かあとで見て分かるように先頭に説明文を書いておこう」という感じなのですが、このdocstringの中に入出力例を書いてテストをしてみようというのがdoctestです。
docstringに入出力を書くだけで実際に動かせるテストコードになり、ソースファイルにドキュメントとして残せるので便利です。
ただし、doctestには注意事項もあります。他のモジュールと組み合わせたテストを書くのに制限がある、ドキュメントなので大量に書くとそれだけソースファイルが肥大化する、テストコードとして必ずしも読みやすいものになるとは限らない…などです。
なお、きちんとしたテストモジュールはunittestといって別に用意されているため、doctestはあくまで簡易版であると捉えていただければと思います。
◆docstringの基本的な書き方
docstringの基本的な書き方は次のとおりです。
- コメントをトリプルクォート("""もしくは''')で囲って記述する。
- 関数またはクラスの先頭に記述する。
defmy_function(): '''docstring-test line1 line2 line3'''
定義の先頭に式などがあるとdocstringとみなされないので注意しましょう。
defmy_function_error(): n = 0'''docstring-test line1 line2 line3'''
◆doctestの簡単な例
実際にdoctestの例を見ていきます。コードはコピペで実行できますので、paiza.IOに貼り付けて試してみてください。
コードの下3行に出てくる呪文のような部分はdoctest を実行するためのコードです。詳しくはこちらの記事が分かりやすく説明してくださっているので参考にしてください。
エラーにならないケース:
2+3=5で正しい結果が得られます。doctestはエラーがない場合、何も出力されません。
defadd(a, b): ''' # 例えば足し算のテストコード>>> add(2, 3) 5''' result = a + b return result if __name__ == '__main__': import doctest doctest.testmod()
エラーになるケース:
間違ったコードを書いて実行するとエラーが出力されます。
defadd(a, b): ''' # 足し算のつもりで式が間違っている>>> add(2, 3) 5''' result = a - b return result if __name__ == '__main__': import doctest doctest.testmod()
期待値と実際得られた結果も教えてくれます。
********************************************************************** File "Main.py", line 4, in __main__.add Failed example: add(2, 3) Expected: 5 Got: -1 ********************************************************************** 1 items had failures: 1 of 1in __main__.add ***Test Failed*** 1 failures.
エラーの有無に関わらず常に出力結果を表示したい場合は、doctest.testmod()の引数にverbose=Trueを指定してください。
defadd(a, b): ''' # 例えば足し算のテストコード>>> add(2, 3) 5''' result = a + b return result if __name__ == '__main__': import doctest doctest.testmod(verbose=True)
上記のコードはエラーにはなりませんが出力結果はこのように表示されます。
Trying: add(2, 3) Expecting: 5 ok 1 items had no tests: __main__ 1 items passed all tests: 1 tests in __main__.add 1 tests in2 items. 1 passed and0 failed. Test passed.
Dランク相当の問題でdoctestを使ってみよう
paizaが提供しているプログラミングスキルを測るスキルチェックでは、制限時間内に問題を解いて提出し、採点されたスコアでランクづけをおこなっています。
paizaのスキルチェック問題について詳しくはこちら
スキルチェックでは、あらかじめテストケースが1~3個用意されており、入力値と出力値が示されています。ただ、このケースを通ったからと言って必ずしも採点で100点を取れるとは限らず、確実にスコアを取るには自分でテストを実施する必要があります。
以前、システム開発におけるテスト工程について解説した記事で、西暦を入れたら和暦を返す簡単なプログラム問題についてテストを実施しました。
今回は同じ問題で、doctestを使ってテストコードを書いてみることにします。
問題:
半角数字で渡された数字Yを西暦とし、「元号(漢字2文字)X年」という表記の和暦に変換します。このとき、1926≦Y≦2030、元号には「昭和」・「平成」のどちらかを表示してください。2020以降は「未定」とだけ表示します。
今回は単純化するため日付は考慮せず、1926年~1988年を昭和、1989年~2019年を平成、2020以降を未定とします。また、「元年」表記はせず「1年」でいいです。
最初に書いたコード:
# 西暦Yをyearで受け取る year = int(input()) # yearを昭和・平成・未定に変換 res = str(year) + "年は、"if(1925< year < 1988): print(res + "昭和" + str(year - 1925) + "年") elif(1988< year < 2019): print(res + "平成" + str(year - 1988) + "年") else: print(res + "未定")
このコードに対して、昭和1年の1926年と昭和63年の1988年の2つをテストします。
doctestを用いたテストコード:
前回は入力エリアに手動で値を入力して実行しましたが、doctestを使うと以下のように書くことができます。
defGengo(year: int) -> str: '''>>> Gengo(1926) #1926年のテスト'1926年は、昭和1年'>>> Gengo(1988) #1988年のテスト'1988年は、昭和63年'''' res = str(year) + "年は、"if(1925< year < 1988): res += "昭和" + str(year - 1925) + "年"elif(1988< year < 2019): res += "平成" + str(year - 1988) + "年"else: res += "未定"return res if __name__ == '__main__': import doctest doctest.testmod(verbose=True)
実行すると…
Trying: Gengo(1926) #1926年のテスト Expecting: '1926年は、昭和1年' ok Trying: Gengo(1988) #1988年のテスト Expecting: '1988年は、昭和63年' ********************************************************************** File "Main.py", line 5, in __main__.Gengo Failed example: Gengo(1988) #1988年のテスト Expected: '1988年は、昭和63年' Got: '1988年は、未定'1 items had no tests: __main__ ********************************************************************** 1 items had failures: 1 of 2in __main__.Gengo 2 tests in2 items. 1 passed and1 failed. ***Test Failed*** 1 failures.
1988年のときの結果が誤っていることが分かりました。「昭和63年」と表示されるべきなのに、現在の処理では「未定」と表示されてしまいます。
よく見てみると「<」記号で範囲指定している箇所で、境界値が条件外になってしまっているところがあります。意外にこの値の範囲指定というのは馬鹿にできなくて、まあまあやりがちなミスなので気をつけましょう。
範囲指定を修正して再度コードを実行してみます。
defGengo(year: int) -> str: '''>>> Gengo(1926) #1926年のテスト'1926年は、昭和1年'>>> Gengo(1988) #1988年のテスト'1988年は、昭和63年'''' res = str(year) + "年は、"if(1925< year <= 1988): res += "昭和" + str(year - 1925) + "年"elif(1988< year <= 2019): res += "平成" + str(year - 1988) + "年"else: res += "未定"return res if __name__ == '__main__': import doctest doctest.testmod(verbose=True)
実行すると2つとも正しい結果が返ってきました。
Trying: Gengo(1926) #1926年のテスト Expecting: '1926年は、昭和1年' ok Trying: Gengo(1988) #1988年のテスト Expecting: '1988年は、昭和63年' ok 1 items had no tests: __main__ 1 items passed all tests: 2 tests in __main__.Gengo 2 tests in2 items. 2 passed and0 failed. Test passed.
(参考)doctestを使った例外処理:
doctestではraise Exceptionのテストができます。yearにint型以外の値が渡された場合にNotIntを出す処理を追加してみます。ここではisinstance(object, class)を使って型の判定をしています。
例外処理については、paizaラーニングで公開している「Python入門編10: 例外処理を理解しよう」講座もぜひ受講してみてください。
classNotInt(ValueError): passdefGengo(year: int) -> str: '''>>> Gengo(1926) #1926年のテスト'1926年は、昭和1年'>>> Gengo(1988) #1988年のテスト'1988年は、昭和63年'>>> Gengo(10.99) #例外テスト "..."は省略を示す Traceback (most recent call last): ... NotInt'''ifnotisinstance(year, int): raise NotInt res = str(year) + "年は、"if(1925< year <= 1988): res += "昭和" + str(year - 1925) + "年"elif(1988< year <= 2019): res += "平成" + str(year - 1988) + "年"else: res += "未定"return res if __name__ == '__main__': import doctest doctest.testmod(verbose=True)
実行してみると「10.99」を渡したところはちゃんとNotIntが返ってきて、テストも通りました。
Trying: Gengo(1926) #1926年のテスト Expecting: '1926年は、昭和1年' ok Trying: Gengo(1988) #1988年のテスト Expecting: '1988年は、昭和63年' ok Trying: Gengo(10.99) #例外テスト "..."は省略を示す Expecting: Traceback (most recent call last): ... NotInt ok 2 items had no tests: __main__ __main__.NotInt 1 items passed all tests: 3 tests in __main__.Gengo 3 tests in3 items. 3 passed and0 failed. Test passed.
それでは最後に昭和・平成・未定のいずれのケースでも正しいかを確認するテストコードを書きましょう。テストケースの導出はこちらの記事を参考にしてください。
ちなみにdoctestを使わず書いたコードは以下のとおりです。何が出力されたら正しいかは別にドキュメントにするか、頭の中で考えないといけません。
この問題は入力値が1つで複雑ではないのでまだいいですが…。
# テストしたい値を配列yearに格納 year = [] year = [1926, 1950, 1988, 1989, 2000, 2019, 2020, 2025, 2030] for i inrange(len(year)): # yearを昭和・平成・未定に変換 res = str(year[i]) + "年は、"if(1925< year[i] <= 1988): print(res + "昭和" + str(year[i] - 1925) + "年") elif(1988< year[i] <= 2019): print(res + "平成" + str(year[i] - 1988) + "年") else: print(res + "未定")
doctestを使って書いたコードは以下の通りです。ちょっとドキュメント部分のボリュームがありますが、入力値と正しい結果が記述できそのままテストができるので、平成の次の元号が発表されたあとのテストも実施しやすそうですね。
defGengo(year: int) -> str: '''>>> Gengo(1926) #1926年のテスト'1926年は、昭和1年'>>> Gengo(1950) #1950年のテスト'1950年は、昭和25年'>>> Gengo(1988) #1988年のテスト'1988年は、昭和63年'>>> Gengo(1989) #1989年のテスト'1989年は、平成1年'>>> Gengo(2000) #2000年のテスト'2000年は、平成12年'>>> Gengo(2019) #2019年のテスト'2019年は、平成31年'>>> Gengo(2020) #2020年のテスト'2020年は、未定'>>> Gengo(2025) #2025年のテスト'2025年は、未定'>>> Gengo(2030) #2030年のテスト'2030年は、未定'''' res = str(year) + "年は、"if(1925< year <= 1988): res += "昭和" + str(year - 1925) + "年"elif(1988< year <= 2019): res += "平成" + str(year - 1988) + "年"else: res += "未定"return res if __name__ == '__main__': import doctest doctest.testmod(verbose=True)
実行してみるとすべてのテストが通り、正しい結果であることを確認できました。
Trying: Gengo(1926) #1926年のテスト Expecting: '1926年は、昭和1年' ok Trying: Gengo(1950) #1950年のテスト Expecting: '1950年は、昭和25年' ok Trying: Gengo(1988) #1988年のテスト Expecting: '1988年は、昭和63年' ok Trying: Gengo(1989) #1989年のテスト Expecting: '1989年は、平成1年' ok Trying: Gengo(2000) #2000年のテスト Expecting: '2000年は、平成12年' ok Trying: Gengo(2019) #2019年のテスト Expecting: '2019年は、平成31年' ok Trying: Gengo(2020) #2020年のテスト Expecting: '2020年は、未定' ok Trying: Gengo(2025) #2025年のテスト Expecting: '2025年は、未定' ok Trying: Gengo(2030) #2030年のテスト Expecting: '2030年は、未定' ok 1 items had no tests: __main__ 1 items passed all tests: 9 tests in __main__.Gengo 9 tests in2 items. 9 passed and0 failed. Test passed.
まとめ
Pythonの標準モジュールであるdoctestを使って、テストコードを書くことについて学んできました。
doctestは例で挙げた問題のように入出力結果を確認したいときに便利ですし、何よりドキュメントとして残すことができるのであとで見ても分かりやすいというのがいいですね。
スキルチェック問題を解くときは提出前に用意されたテストケースで確認することはもちろん、自分でテストコードを書いて確認してみることが確実にスコアを伸ばすことにつながります。
また、テストケースを考えたりテストコードを書いたりということは実務でも役に立ちますので慣れておいて損はありません。
もしこの記事の内容が「ちょっと難しかったな…」と感じた人はpaizaラーニングの「Python3入門編」(完全無料)を受講してから再度チャレンジしてみてください。
Python入門編は全11レッスンあってけっこうなボリュームなので、四則演算とfor文・if文あたりは問題ないという方は「Python入門編7:関数を理解しよう」を受講すればdoctestを理解できると思います。
「paizaラーニング」では、未経験者でもブラウザさえあれば、今すぐプログラミングの基礎が動画で学べるレッスンを多数公開しております。
詳しくはこちら
そしてpaizaでは、Webサービス開発企業などで求められるコーディング力や、テストケースを想定する力などが問われるプログラミングスキルチェック問題も提供しています。
スキルチェックに挑戦した人は、その結果によってS・A・B・C・D・Eの6段階のランクを取得できます。必要なスキルランクを取得すれば、書類選考なしで企業の求人に応募することも可能です。「自分のプログラミングスキルを客観的に知りたい」「スキルを使って転職したい」という方は、ぜひチャレンジしてみてください。
詳しくはこちら