Minitest

RubyテストフレームワークTDDBDDモックベンチマーク

Minitest

概要

MinitestはRuby用の完全なテストスイートです。TDD、BDD、モック、ベンチマークをサポートする軽量で高速なテストフレームワークとして設計されています。Ruby 1.9以降の標準ライブラリとして採用され、Test::Unitの後継として位置づけられています。シンプルで直感的なAPIと豊富な機能により、現代のRuby開発における事実上のテスト標準となっています。

詳細

主な特徴

  • 完全なテストスイート: TDD、BDD、モック、ベンチマークを統合提供
  • 軽量設計: 最小限の依存関係で高速動作
  • 柔軟なスタイル: Test::Unitスタイルとspec(RSpec風)スタイルの両方をサポート
  • スタブ機能: 組み込みのスタブ・モック機能
  • 並列実行: マルチプロセス・マルチスレッドでの並列テスト実行
  • ベンチマーク: 内蔵されたパフォーマンス測定機能
  • カスタマイズ可能: プラグインとリポーターによる拡張性

アーキテクチャ

Minitestは以下の核心コンポーネントで構成されています:

  • Test: 基本テストクラス(Test::Unit互換)
  • Spec: spec記法によるBDDスタイルテスト
  • Mock: モックオブジェクトとスタブ機能
  • Benchmark: パフォーマンス測定とベンチマーク
  • Reporter: テスト結果の表示とフォーマット
  • Parallel: 並列テスト実行エンジン

メリット・デメリット

メリット

  • 標準統合: Ruby標準ライブラリとして安定した提供
  • 高性能: 軽量設計による高速なテスト実行
  • 多様なスタイル: TDDとBDDの両方に対応
  • 組み込み機能: モック、スタブ、ベンチマークが標準装備
  • 並列実行: 大規模テストスイートの高速実行
  • 豊富なアサーション: 多様なテストシナリオに対応

デメリット

  • シンプル設計: 高度なBDD機能はRSpecに劣る
  • エコシステム: RSpecほど豊富なプラグインエコシステムはない
  • 学習コスト: 複数のスタイルによる初学者の混乱
  • 高度なモック: より複雑なモック機能は外部ライブラリが必要

参考ページ

書き方の例

基本的なTest::Unitスタイル

require 'minitest/autorun'

class CalculatorTest < Minitest::Test
  def setup
    @calculator = Calculator.new
  end
  
  def test_addition
    assert_equal 4, @calculator.add(2, 2)
    assert_equal 0, @calculator.add(-1, 1)
    assert_equal -3, @calculator.add(-1, -2)
  end
  
  def test_subtraction
    assert_equal 0, @calculator.subtract(2, 2)
    assert_equal 5, @calculator.subtract(8, 3)
    assert_equal -1, @calculator.subtract(2, 3)
  end
  
  def test_division_by_zero
    assert_raises(ZeroDivisionError) do
      @calculator.divide(5, 0)
    end
  end
  
  def test_with_detailed_message
    result = @calculator.multiply(3, 4)
    assert_equal 12, result, "3 * 4 should equal 12"
  end
end

Specスタイル(BDD)

require 'minitest/autorun'

describe Calculator do
  before do
    @calculator = Calculator.new
  end
  
  describe "addition" do
    it "adds positive numbers" do
      _(@calculator.add(2, 3)).must_equal 5
    end
    
    it "adds negative numbers" do
      _(@calculator.add(-2, -3)).must_equal -5
    end
    
    it "handles zero" do
      _(@calculator.add(0, 5)).must_equal 5
      _(@calculator.add(5, 0)).must_equal 5
    end
  end
  
  describe "division" do
    it "divides numbers correctly" do
      _(@calculator.divide(10, 2)).must_equal 5
    end
    
    it "raises error for division by zero" do
      -> { @calculator.divide(5, 0) }.must_raise ZeroDivisionError
    end
  end
  
  describe "edge cases" do
    it "handles floating point numbers" do
      result = @calculator.add(0.1, 0.2)
      _(result).must_be_within_delta 0.3, 0.001
    end
  end
end

豊富なアサーション

require 'minitest/autorun'

class AssertionTest < Minitest::Test
  def test_equality_assertions
    # 基本的な等価性
    assert_equal "hello", "hello"
    refute_equal "hello", "world"
    
    # オブジェクトの同一性
    obj = Object.new
    assert_same obj, obj
    refute_same Object.new, Object.new
  end
  
  def test_boolean_assertions
    assert true
    refute false
    
    # nil チェック
    assert_nil nil
    refute_nil "not nil"
  end
  
  def test_collection_assertions
    array = [1, 2, 3, 4, 5]
    
    assert_includes array, 3
    refute_includes array, 6
    
    assert_empty []
    refute_empty array
  end
  
  def test_type_assertions
    assert_instance_of String, "hello"
    assert_kind_of Numeric, 42
    assert_respond_to [], :push
  end
  
  def test_pattern_assertions
    assert_match /hello/, "hello world"
    refute_match /goodbye/, "hello world"
  end
  
  def test_exception_assertions
    assert_raises(ArgumentError) do
      raise ArgumentError, "test error"
    end
    
    exception = assert_raises(RuntimeError) do
      raise "custom error"
    end
    assert_equal "custom error", exception.message
  end
  
  def test_numeric_assertions
    assert_in_delta 3.14159, Math::PI, 0.001
    assert_in_epsilon 1000, 1010, 0.1 # 10%の誤差を許容
    
    assert_operator 5, :>, 3
    assert_operator "abc", :<, "def"
  end
end

スタブとモック

require 'minitest/autorun'

class StubMockTest < Minitest::Test
  def test_stubbing_methods
    # メソッドのスタブ化
    Time.stub :now, Time.at(0) do
      assert_equal Time.at(0), Time.now
    end
    
    # 複数の値を順次返すスタブ
    sequence = [1, 2, 3].each
    Number.stub :random, -> { sequence.next } do
      assert_equal 1, Number.random
      assert_equal 2, Number.random
      assert_equal 3, Number.random
    end
  end
  
  def test_mock_objects
    # モックオブジェクトの作成
    mock_logger = Minitest::Mock.new
    
    # 期待する呼び出しの設定
    mock_logger.expect :info, true, ["Processing started"]
    mock_logger.expect :info, true, ["Processing completed"]
    
    # モックを使用するコードのテスト
    processor = DataProcessor.new(mock_logger)
    processor.process_data("test data")
    
    # 期待した呼び出しがすべて行われたかを検証
    mock_logger.verify
  end
  
  def test_partial_mocking
    user = User.new("John")
    
    # 既存オブジェクトの一部メソッドをスタブ
    user.stub :save, true do
      assert user.update_profile("New Name")
    end
  end
  
  def test_stub_with_different_arguments
    calculator = Calculator.new
    
    # 引数に応じて異なる戻り値を設定
    calculator.stub :add, 10 do
      calculator.stub :add, 20 do
        assert_equal 10, calculator.add(2, 3)
      end
    end
  end
end

データ駆動テスト

require 'minitest/autorun'

class DataDrivenTest < Minitest::Test
  # テストデータの定義
  TEST_CASES = [
    { input: [2, 3], expected: 5, operation: :add },
    { input: [10, 4], expected: 6, operation: :subtract },
    { input: [3, 4], expected: 12, operation: :multiply },
    { input: [15, 3], expected: 5, operation: :divide }
  ]
  
  def test_calculator_operations
    calculator = Calculator.new
    
    TEST_CASES.each do |test_case|
      result = calculator.send(test_case[:operation], *test_case[:input])
      assert_equal test_case[:expected], result,
        "Failed #{test_case[:operation]} with #{test_case[:input]}"
    end
  end
  
  # 動的にテストメソッドを生成
  TEST_CASES.each_with_index do |test_case, index|
    define_method "test_#{test_case[:operation]}_case_#{index}" do
      calculator = Calculator.new
      result = calculator.send(test_case[:operation], *test_case[:input])
      assert_equal test_case[:expected], result
    end
  end
end

並列テスト実行

require 'minitest/autorun'

# 並列実行の有効化
parallelize_me!

class ParallelTest < Minitest::Test
  def test_independent_operation_1
    # 独立したテスト処理
    sleep 0.1
    assert_equal 4, 2 + 2
  end
  
  def test_independent_operation_2
    # 独立したテスト処理
    sleep 0.1
    assert_equal 6, 2 * 3
  end
  
  def test_independent_operation_3
    # 独立したテスト処理
    sleep 0.1
    assert_equal 1, 3 - 2
  end
end

# 並列実行を避けたいテストクラス
class SerialTest < Minitest::Test
  # このクラスは並列実行しない
  def test_shared_resource_operation
    # 共有リソースを使用するテスト
    GlobalCounter.increment
    assert_equal 1, GlobalCounter.value
  end
end

ベンチマークテスト

require 'minitest/autorun'
require 'minitest/benchmark'

class BenchmarkTest < Minitest::Benchmark
  # ベンチマーク対象のサイズ範囲を指定
  def self.bench_range
    [100, 1_000, 10_000, 100_000]
  end
  
  def bench_array_creation
    assert_performance_linear 0.9999 do |n|
      Array.new(n) { |i| i }
    end
  end
  
  def bench_hash_lookup
    data = Hash[(1..input_size).map { |i| [i, i * 2] }]
    
    assert_performance_constant 0.99 do |n|
      n.times { data[rand(input_size)] }
    end
  end
  
  def bench_string_concatenation
    # 線形性能を期待
    assert_performance_linear 0.9 do |n|
      str = ""
      n.times { |i| str += i.to_s }
    end
  end
  
  def bench_custom_algorithm
    # カスタムアルゴリズムのベンチマーク
    assert_performance_logarithmic 0.9 do |n|
      binary_search(generate_sorted_array(n), n / 2)
    end
  end
  
  private
  
  def input_size
    100_000
  end
  
  def binary_search(array, target)
    # バイナリサーチの実装
    low, high = 0, array.length - 1
    
    while low <= high
      mid = (low + high) / 2
      case array[mid] <=> target
      when -1 then low = mid + 1
      when 1 then high = mid - 1
      else return mid
      end
    end
    
    -1
  end
  
  def generate_sorted_array(size)
    (1..size).to_a
  end
end

カスタムアサーションとヘルパー

require 'minitest/autorun'

module CustomAssertions
  def assert_valid_email(email, message = nil)
    message ||= "Expected #{email} to be a valid email"
    email_regex = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
    assert email_regex.match?(email), message
  end
  
  def assert_json_response(response, expected_status = 200)
    assert_equal expected_status, response.status
    assert_match /application\/json/, response.content_type
    JSON.parse(response.body)
  rescue JSON::ParserError
    flunk "Response body is not valid JSON: #{response.body}"
  end
  
  def assert_difference(expression, difference = 1, message = nil, &block)
    before = eval(expression)
    yield
    after = eval(expression)
    
    actual_difference = after - before
    assert_equal difference, actual_difference, message
  end
end

class CustomTest < Minitest::Test
  include CustomAssertions
  
  def test_email_validation
    assert_valid_email "[email protected]"
    
    assert_raises(Minitest::Assertion) do
      assert_valid_email "invalid-email"
    end
  end
  
  def test_json_api_response
    response = simulate_api_call
    data = assert_json_response(response, 200)
    
    assert_includes data, 'user_id'
    assert_equal 'John', data['name']
  end
  
  def test_counter_increment
    counter = Counter.new
    
    assert_difference('counter.value', 2) do
      counter.increment
      counter.increment
    end
  end
  
  private
  
  def simulate_api_call
    OpenStruct.new(
      status: 200,
      content_type: 'application/json',
      body: '{"user_id": 123, "name": "John"}'
    )
  end
end

高度なテスト設定

require 'minitest/autorun'

# カスタムリポーターの作成
class CustomReporter < Minitest::AbstractReporter
  def initialize(io = $stdout, options = {})
    super
    @io = io
  end
  
  def start
    @io.puts "🚀 Starting test suite..."
  end
  
  def record(result)
    case result.result_code
    when '.'
      @io.print "✅"
    when 'F'
      @io.print "❌"
    when 'E'
      @io.print "💥"
    when 'S'
      @io.print "⏭️"
    end
  end
  
  def report
    @io.puts "\n🏁 Test suite completed!"
    super
  end
end

# テスト実行前の設定
Minitest.reporter = CustomReporter.new

class AdvancedConfigTest < Minitest::Test
  # テストの条件分岐
  def test_environment_specific
    skip "This test only runs in production" unless ENV['RAILS_ENV'] == 'production'
    
    # 本番環境固有のテスト
    assert production_feature_enabled?
  end
  
  def test_with_timeout
    # タイムアウト付きテスト(外部ライブラリ使用)
    Timeout::timeout(5) do
      slow_operation
    end
  rescue Timeout::Error
    flunk "Operation took too long"
  end
  
  def test_flaky_test_with_retry
    retries = 0
    
    begin
      # 不安定なテスト処理
      flaky_operation
    rescue StandardError => e
      retries += 1
      retry if retries < 3
      raise e
    end
  end
  
  private
  
  def production_feature_enabled?
    # 本番環境のフィーチャーフラグチェック
    true
  end
  
  def slow_operation
    sleep 1
    true
  end
  
  def flaky_operation
    # 30%の確率で失敗するテスト
    raise "Flaky failure" if rand < 0.3
    true
  end
end