はじめに
本記事では、ソフトウェアテストの基本事項について解説を行う。
私は大学生であり、最近までソフトウェアテストについて全く学んでこなかった。しかし、ACTを利用した計算器を作成した際に、初めてソフトウェアテストの重要性を認識するようになった。
本記事は、私と同様にソフトウェアテストに関する予備知識を持たない人を対象としている。
ソフトウェアテストとは何か?
ソフトウェアテストというのは、作成したアプリケーションが開発者の想定通りに動作するか評価・検証する工程である。このテストは、リリース後に大きなトラブルへと発展することを防ぐ役割を担っている。
想定した通りに動作するというのはどういうことかを、計算器を例にとり考える。簡略化の為、ここで扱う計算器は四則演算と括弧つき計算を実装したものであると仮定する。
ここで想定される動作は次のようなものである。
-
2 + 3 =
$\rightarrow$ 結果 :5
-
4 * (1 + 2) =
$\rightarrow$ 結果 :12
-
10 / 0 =
$\rightarrow$ 結果 : エラーとして処理される(不正な計算) -
(()) 1 2 4
$\rightarrow$ 結果 : エラーとして処理される(不正な入力)
これらの入力が行われた時に、計算器が期待される動作を行うかどうかを、テストを通じて確認および評価を行う。つまり、正しい入力に対しては正しい数値結果を返し、誤った入力に対しては適切なエラーメッセージを返す。
バグとエラー
ここではバグとエラーの違いについて解説する。
アプリがうまく動作しない場合に、バグとエラーという単語は使われる。「バグのあるプログラム」や「プログラムがエラーを出した」といった表現は、実際の会話やドキュメントで頻繁に使用される。
両方の単語から「うまくいっていない感じ」は読み取れるが、その違いというのは何だろうか?
両者は以下のように区別される。
用語 | 説明 |
---|---|
エラー | ソフトウェアのソースコードの中に含まれる誤りを指す。プログラムの内部に存在する欠陥を意味する。 |
バグ | エラーが原因となって、実際に発生する予期しない挙動や障害を指す。計算結果が誤っていた李、アプリがクラッシュするなどの現象である |
このように、エラーは原因であり、バグはその結果として現れる現象である。
4. エラーの種類と分類
4.1. エラーの種類と分類について
前項では、バグとエラーの違いについて解説した。ここでは、エラーの分類について述べる。
エラーは、主に以下のように分類される。
- 構文エラー(Syntax Error)
- 論理エラー
- 実行時エラー(Runtime Error)
- 例外(Exception)との違いと関係
4.2. 構文エラー(Syntax Error)
構文エラーとは、プログラミング言語の文法に違反しているエラーである。全てのプログラミング言語には、文法が定義されている。この文法から逸脱したコードを書くと、構文エラーとなる。
4.2.1. 構文エラーのコード例(Python)
以下に、Name
を10回表示するPythonコードを示す。しかしこのコードは、Pythonの for
の構文に違反している。
name = "Name"
for i in range(10) # この行の最後に ':' が必要
print(name)
4.2.2. 実行結果(構文エラー)
for i in range(10)
^
SyntaxError: expected ':'
4.2.2. の実行結果から、Python
の文法に逸脱した for
文を書いて実行すると、SyntaxError
が返されることが分かった。
4.3. 論理エラー
論理エラーとは、構文的には正しいものの、意図した動作を行わないエラーである。つまり、コンパイルや構文解析時点では問題が検出されないが、動作結果が意図したものと異なるという形で表面化する。
例として、以下に商品$A$を購入した時に、割引$n$%を適用するPythonプログラムを示す。
4.3.1. 論理エラーのコード例(Python)
def discount_merchandise(price, discount):
return price - discount
price_of_A = 500
discount = 10 # 10%の割引が発生する
print(discount_merchandise(price_of_A, discount))
4.3.2. 実行結果(論理エラー)
490
この実行結果では500円から「10」を単純に減算しているため、出力は490となっている。しかしここでの「10」は10%を意味しており、正しくは割合として計算されるべきである。
本来商品$A$の値段が500円で割引が10%発生した場合、合計金額は、
\begin{align}
500 * (1 - \frac{10}{100}) &= 500 * \frac{90}{100} \\
&=5 * 90 \\
&=450
\end{align}
より、450円が正しい結果である。しかしコードでは、割引が%であるのに単純に引き算をおこない求めているため誤りが発生している。
4.4. 実行時エラー(Runtime Error)
実行時エラーとは、プログラム実行中に発生し、プログラムを即座にクラッシュさせるエラーである。コンパイラやインタープリタによってコードがコンパイルまたは解釈された後に発生するので「構文エラー」ではない。
例として、スレッドを2重に起動させるPythonプログラムを示す。(これは実行時エラーとなる)
4.4.1. 実行時エラーのコード例(Python)
import threading
def worker():
pass
t = threading.Thread(target=worker)
t.start()
t.start() # 二度目のstart
4.4.2. 実行結果(実行時エラー)
RuntimeError: threads can only be started once
この実行結果から分かるように、同一のスレッドオブジェクトに対して start() を2回呼び出したため、RuntimeError が発生している。このエラーは、構文エラーや論理エラーとは異なり、プログラムが動作して初めて検出される実行時エラーである。
start()
スレッドの活動を開始します。
このメソッドは、スレッドオブジェクトあたり一度しか呼び出してはなりません。 start() は、オブジェクトの run() メソッドが個別の処理スレッド中で呼び出されるように調整します。
同じスレッドオブジェクトに対し、このメソッドを2回以上呼び出した場合、 RuntimeError を送出します。
— Python公式ドキュメント(threading モジュール)
4.5. 例外
例外とは、プログラムの実行中に発生するエラーや予期しない状態のうち、プログラムが適切な処理(例外処理)を行うことで実行を継続可能なものである。すなわち、例外は想定内の異常である一方、エラーは想定外の異常である。
例として入力した数値の演算を行うPythonプログラムを下記に示す。このプログラムでは、演算時に ZeroDivisionError
をキャッチしてエラーメッセージを表示させている。
4.5.1. 例外のコード例(Python)
def add(a, b):
return a + b
def sub(a, b):
return a - b
def divide(a, b):
try:
result = a / b
except ZeroDivisionError as e:
return f"無効な除算操作({a} / {b})が行われました"
else:
return result
def mul(a, b):
return a * b
def calculate(a, b, operator):
if operator == "+":
return add(a, b)
elif operator == "-":
return sub(a, b)
elif operator == "*":
return mul(a, b)
elif operator == "/":
return divide(a, b)
else:
return f"不正な演算子: {operator}"
a, op, b = input("式を入力(例: 5 + 3): ").split()
print(f"結果: {calculate(int(a), int(b), op)}")
4.5.2. 実行結果(例外)
入力例1 - 入力:5 + 2
式を入力(例: 5 + 3): 5 + 2
結果: 7
入力例2 - 入力:10 / 0
式を入力(例: 5 + 3): 10 / 0
結果: 無効な除算操作(10 / 0)が行われました
入力例2において、0除算という不正な演算が行われたが、try-except
によって、ZeroDivisionError
をキャッチして、プログラムを異常終了させずに、適切なメッセージを表示させることができた。
実際のアプリにおいて、ユーザーが常に開発者の想定した入力を行うとは限らない。したがって、正しい入力に基づく論理関係と同様に、例外の補足と適切な対応も同程度に重要である。
5. ソフトウェアテストの概要
ソフトウェアテストとは、プログラムが意図したとおりに動作するかを確認するための実行検証プロセスであり、状況にもよるが、開発時間の10% ~ 20%位を占めると言われる。テストを行いエラーが検出された場合は、コードを修正し、再度テストを行う。必要に応じて、リファクタリングを行うこともある。
開発のフローチャートを以下に示す。
テスト駆動開発(Test-Driven Development: TDD)
ソフトウェア開発において、テストは重要な工程である。このテストを実装よりも先に記述する設計手法は、「テスト駆動開発(TDD)」と呼ばれる。
テスト駆動開発の基本サイクルは、主に、「レッド・グリーン・リファクタリング」から構成される
1.レッド:動作しない、おそらく最初のうちはコンパイルも通らないテストを1つ書く。
2.グリーン:そのテストを迅速に動作させる。このステップでは罪を犯してもよい。
3.リファクタリング:テストを通すために発生した重複をすべて除去する。
(出典:「テスト駆動開発(TDD)とは?目的やメリット・デメリット、やり方を解説」より)
このように、TDDは単なるテスト手法ではなく、「テストを通じて設計と実装を導くための開発手法」である。
6. テストの種類とアプローチ
ここでは、テストの「対象」と「方法」に分類して説明する。
6.1. テストレベル
テストは階層構造に基づいて、以下のように分類される。
階層 | ソフトウェア | 目的 |
---|---|---|
高 | ソフトウェア製品全体 | 受入テスト・統合テスト |
中 | モジュール、クラス、オブジェクト、メソッド、関数 | ユニットテスト |
低 | 数行のコード | 一般的なソフトウェアテスト |
基礎 | 単一のコード | エラーチェック |
6.1.1. 単体テスト(ユニットテスト)
最小のテストの単位であり、個々の関数・メソッド・クラス等に対して行う。主に開発者が実装後に行い、期待した出力が得られるかを確認する。
例:add(a, b)
関数に、add(3, 5)
を与えて8
が返ることを確認する。
仕様ツール:Python の場合は、 unittest
を利用する。
6.1.2. 統合テスト
複数のモジュール・関数・APIが正しく連携するのかを確認する。
例:ログインAPI -> トークンの発行 -> ユーザー情報取得の一連の流れが正常に動くか
テストの順序には「トップダウン」「ボトムアップ」などの戦略も存在する。
トップダウンアプローチ:メイン関数やメソッドで定義された主制御経路から制御フローを辿って、上の階層から順にテストを実行する。
ボトムアップアプローチ:最下層のコンポーネント(関数やクラスなど)を実行しテストを行う。ここから上位の階層に向かってテストを実行していく。
6.1.3. 受入テスト
受入テストは、テストプロセスの階層における最後のステップであえる。このテストは、開発者が行うのではなくクライアントが行うもの。より本番環境に近い環境で行う。
6.1.4. その他のテスト
ビジュアルテスト:アプリが正しい要素、コンテンツを生成していても見た目が想定通りにならないケースがある。ビジュアルテストでは、モックアップのスクリーンショット群に基づいて、各ページが個別にチェックされる。
インストゥルメント化テスト:特定のハードウェア環境で動作するように設計されたソフトウェアについては、その環境下で正しく機能するかをテストする。
6.2. ソフトウェアテストの方法
ソフトウェアテストは、実装の見える・見えないという観点からも分類できる。代表的な分類は以下の2つである。
ブラックボックス:入力と出力のみに着目した手法。入力に対して、意図した出力であればよい。
ホワイトボックステスト:入力と出力以外にも、内部の条件分岐が動いているかを検証する手法。
一般的には、ユニットテストや静的解析はホワイトボックステストの一種であり、受入テストやE2Eテストはブラックボックステストに該当する。
7. テストの実施方法
テストの実施方法は大きく分けて2つある。
- 手動テスト
- 自動化テスト
7.1 手動テスト
手動テストとは、自動化ツールやスクリプトを使用せずに手動で行うテストのことを指す。様々な入力値を手動で入力し、期待される出力が表示されるかどうかを確認する。
様々なエッジケースをより柔軟にテストできる一方で、都度入力を手動で行うため時間がかかる。
7.2 自動化テスト
手動テストとは異なり、自動化テストは自動化されたツールやスクリプトを使って行うテストのことを指す。テストのプロセスを迅速化し、ミスを減らす。
7.2.1. 自動化テスト例(Python)
自動化テストの例として、抽象構文木を用いた計算器を作成したときの自動化テストのPythonスクリプトを示す。
from unittest import TestCase
from app.engine import ExpressionEngine
class TestEvaluator(TestCase):
def eval_expr(self, expr):
engine = ExpressionEngine(expr)
return engine.evaluate()
def test_basic_operation(self):
self.assertEqual(self.eval_expr("1 + 2"), 3)
self.assertEqual(self.eval_expr("4 - 2"), 2)
self.assertEqual(self.eval_expr("3 * 2"), 6)
self.assertEqual(self.eval_expr("8 / 2"), 4)
self.assertEqual(self.eval_expr(124 - 3), 121)
def test_multiple_operations(self):
self.assertEqual(self.eval_expr("1 ++++++++++ 2"), 3)
self.assertEqual(self.eval_expr("1 + 2 -- 3"), 6)
self.assertEqual(self.eval_expr("--- 3"), -3)
self.assertEqual(self.eval_expr("2 +++ 3 -- 1"), 6)
def test_unary_operation(self):
self.assertEqual(self.eval_expr("-5"), -5)
self.assertEqual(self.eval_expr("+3"), 3)
self.assertEqual(self.eval_expr("-(3 + 2)"), -5)
self.assertEqual(self.eval_expr("-(2 * 3)"), -6)
def test_parentheses(self):
self.assertEqual(self.eval_expr("(1 + 2) * 3"), 9)
self.assertEqual(self.eval_expr("3 * (2 + 1)"), 9)
self.assertEqual(self.eval_expr("(4 - 2) / 2"), 1)
self.assertEqual(self.eval_expr("2 * (3 + 4) - 5"), 9)
def test_division_and_float(self):
self.assertEqual(self.eval_expr("4 / 2"), 2)
self.assertEqual(self.eval_expr("5 / 2"), 2.5)
self.assertEqual(self.eval_expr("7 / 3"), 2.3333333333333335)
self.assertEqual(self.eval_expr("1.5 * 2"), 3)
def test_complex_expression(self):
self.assertEqual(self.eval_expr("1 + 2 * (-3) + 4 - (-5) / 2"), 1.5)
self.assertEqual(self.eval_expr("3 * (2 + 1) - ((-4) / 2) * (-1)"), 7)
def test_one_number(self):
self.assertEqual(self.eval_expr("5"), 5)
self.assertEqual(self.eval_expr("0"), 0)
self.assertEqual(self.eval_expr("-3"), -3)
self.assertEqual(self.eval_expr("1.5"), 1.5)
def test_empty_expression(self):
with self.assertRaises(Exception):
self.eval_expr("")
with self.assertRaises(Exception):
self.eval_expr(" ")
with self.assertRaises(Exception):
self.eval_expr("()")
def test_invalid_symbols(self):
with self.assertRaises(Exception):
self.eval_expr("1 %")
with self.assertRaises(Exception):
self.eval_expr("((1 # 2)")
with self.assertRaises(Exception):
self.eval_expr("1 @ 0")
with self.assertRaises(Exception):
self.eval_expr("1 | 2")
with self.assertRaises(Exception):
self.eval_expr("==1 2-1 fs93")
def test_invalid_expressions(self):
with self.assertRaises(Exception):
self.eval_expr("1 +")
with self.assertRaises(Exception):
self.eval_expr("((1 + 2)")
with self.assertRaises(Exception):
self.eval_expr("1 / 0")
with self.assertRaises(Exception):
self.eval_expr("1 ** 2")
with self.assertRaises(Exception):
self.eval_expr("1 2")
with self.assertRaises(Exception):
self.eval_expr()
この計算器は、コマンドラインから入力された数式を評価し、その計算結果を返す。定義されている動作は、
- () を使った演算
- 四則演算
- 正負の演算
である。このテストの目的は以下のように分類される。
- 正常な入力に対して、期待される計算結果が返されるかどうかの検証(正常系)
- 文法的に誤った入力に対する例外の確認(構文エラー)
- 演算中の例外処理(0除算などのランタイムエラー)
- 想定外の文字や入力に対する防御
である。
先ほどの unittest
を実行すると次のようになる。
入力:
> python -m unittest discover
出力:
..........
----------------------------------------------------------------------
Ran 10 tests in 0.004s
OK
出力結果から、設定したテストケースは想定した通りに動作するのが確認できる。
8. テストケース設計
前項ではテストの実施方法について解説した。ここではテストケースについて述べる。
テストケースは以下の2つから選択できる。
- ガイドラインベースのテスト
- パーティションベースのテスト
8.1. ガイドラインベースのテスト
ガイドラインベースのテストとは、過去の経験や既存の開発指針に基づいて、頻出するバグや人為的ミスを予防するために設計されるテスト手法である。
例としては以下が挙げられる:
- 関数に対して
null
や空文字列を渡す - 異常型の入力(整数を期待する関数に文字列を渡す等)
- 境界値(最大値・最小値など)のテスト
- 例外が適切かの確認
8.2. パーティションベースのテスト
パーティションベースのテストでは、ソフトウェアへの全ての可能な入力が考慮され、プログラムによって同じように処理される入力値のグループまたはカテゴリが識別される。
これは境界線周辺にテストケースを作成することで、テストケースの数を最小限に抑える。
たとえば、「0〜100の整数を受け取り 'OK' を返す関数」があった場合、以下のようなパーティションに分類できる:
- 妥当な入力値:0、50、100
- 小さすぎる値:-1
- 大きすぎる値:101
- 無効な型:文字列や
None
など
8.3. 両手法の比較
項目 | ガイドラインベース | パーティションベース |
---|---|---|
根拠 | 経験則・過去のバグ | 仕様・入力の分類 |
設計の視点 | よくある失敗パターンへの備え | 入力空間の効率的カバー |
目的 | バグを未然に防ぐ | 最小限のケースで網羅性を確保 |
テスト項目の選び方 | 開発者・QA の経験に依存 | 論理的に導かれる入力グループから代表値を選定 |
代表例 | nullチェック、空文字、境界値、型違い | 正常/異常/境界グループに分け、代表値をテスト |
利用局面 | バグが発生しやすい箇所全般 | 明確な入力仕様がある関数・APIなど |
9. おわりに
今回はソフトウェアテストとエラーについて解説した。
10. 参考資料
[1] Python公式ドキュメント 「threading --- スレッドベースの並列処理」
[2] Recursion
[3] テスト駆動開発(TDD)とは?目的やメリット・デメリット、やり方を解説