Rails 8 Tests: ๐Ÿ”„ TDD vs ๐ŸŽญ BDD | System Tests

Testโ€‘Driven Development (TDD) and Behaviorโ€‘Driven Development (BDD) are complementary testing approaches that help teams build robust, maintainable software by defining expected behaviour before writing production code. In TDD, developers write small, focused unit tests that fail initially, then implement just enough code to make them pass, ensuring each component meets its specification. BDD extends this idea by framing tests in a global language that all stakeholdersโ€”developers, QA, and product ownersโ€”can understand, using human-readable scenarios to describe system behaviour. While TDD emphasizes the correctness of individual units, BDD elevates collaboration and shared understanding by specifying the “why” and “how” of features in a narrative style, driving development through concrete examples of desired outcomes.

๐Ÿ”„ TDD vs ๐ŸŽญ BDD: Methodologies vs Frameworks

๐Ÿง  Understanding the Concepts

๐Ÿ”„ TDD (Test Driven Development)
  • Methodology/Process: Write test โ†’ Fail โ†’ Write code โ†’ Pass โ†’ Refactor
  • Focus: Testing the implementation and correctness
  • Mindset: “Does this code work correctly?”
  • Style: More technical, code-focused
๐ŸŽญ BDD (Behavior Driven Development)
  • Methodology/Process: Describe behavior โ†’ Write specs โ†’ Implement โ†’ Verify behavior
  • Focus: Testing the behavior and user requirements
  • Mindset: “Does this behave as expected from user’s perspective?”
  • Style: More natural language, business-focused

๐Ÿ› ๏ธ Frameworks Support Both Approaches

๐Ÿ“‹ RSpec (Primarily BDD-oriented)
# BDD Style - describing behavior
describe "TwoSum" do
  context "when given an empty array" do
    it "should inform user about insufficient data" do
      expect(two_sum([], 9)).to eq('Provide an array with length 2 or more')
    end
  end
end
โš™๏ธ Minitest (Supports Both TDD and BDD)
๐Ÿ”ง TDD Style with Minitest
class TestTwoSum < Minitest::Test
  # Testing implementation correctness
  def test_empty_array_returns_error
    assert_equal 'Provide an array with length 2 or more', two_sum([], 9)
  end

  def test_valid_input_returns_indices
    assert_equal [0, 1], two_sum([2, 7], 9)
  end
end
๐ŸŽญ BDD Style with Minitest
describe "TwoSum behavior" do
  describe "when user provides empty array" do
    it "guides user to provide sufficient data" do
      _(two_sum([], 9)).must_equal 'Provide an array with length 2 or more'
    end
  end

  describe "when user provides valid input" do
    it "finds the correct pair indices" do
      _(two_sum([2, 7], 9)).must_equal [0, 1]
    end
  end
end

๐ŸŽฏ Key Differences in Practice

๐Ÿ”„ TDD Approach
# 1. Write failing test
def test_two_sum_with_valid_input
  assert_equal [0, 1], two_sum([2, 7], 9)  # This will fail initially
end

# 2. Write minimal code to pass
def two_sum(nums, target)
  [0, 1]  # Hardcoded to pass
end

# 3. Refactor and improve
def two_sum(nums, target)
  # Actual implementation
end
๐ŸŽญ BDD Approach
# 1. Describe the behavior first
describe "Finding two numbers that sum to target" do
  context "when valid numbers exist" do
    it "returns their indices" do
      # This describes WHAT should happen, not HOW
      expect(two_sum([2, 7, 11, 15], 9)).to eq([0, 1])
    end
  end
end

๐Ÿ“Š Summary Table

AspectTDDBDD
FocusImplementation correctnessUser behavior
LanguageTechnicalBusiness/Natural
FrameworksAny (Minitest, RSpec, etc.)Any (RSpec, Minitest spec, etc.)
Test Namestest_method_returns_value"it should behave like..."
AudienceDevelopersStakeholders + Developers

๐ŸŽช The Reality

  • RSpec encourages BDD but can be used for TDD
  • Minitest is framework-agnostic – supports both approaches equally
  • Your choice of methodology (TDD vs BDD) is independent of your framework choice
  • Many teams use hybrid approaches – BDD for acceptance tests, TDD for unit tests

The syntax doesn’t determine the methodology – it’s about how you think and approach the problem!

System Tests ๐Ÿ’ปโš™๏ธ

System tests in Rails (located in test/system/*) are full-stack integration tests that simulate real user interactions with your web application. They’re the highest level of testing in the Rails testing hierarchy and provide the most realistic testing environment.

System tests actually launch a real web browser (or headless browser) and interact with your application just like a real user would. Looking at our Rails app’s configuration: design_studio/test/application_system_test_case.rb

driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]

This means our system tests run using:

  • Selenium WebDriver (browser automation tool)
  • Headless Chrome (Chrome browser without UI)
  • 1400×1400 screen size for consistent testing

Code Snippets from:actionpack-8.0.2/lib/action_dispatch/system_test_case.rb

# frozen_string_literal: true

# :markup: markdown

gem "capybara", ">= 3.26"

require "capybara/dsl"
require "capybara/minitest"
require "action_controller"
require "action_dispatch/system_testing/driver"
require "action_dispatch/system_testing/browser"
require "action_dispatch/system_testing/server"
require "action_dispatch/system_testing/test_helpers/screenshot_helper"
require "action_dispatch/system_testing/test_helpers/setup_and_teardown"

module ActionDispatch
  # # System Testing
  #
  # System tests let you test applications in the browser. Because system tests
  # use a real browser experience, you can test all of your JavaScript easily from
  # your test suite.
  #
  # To create a system test in your application, extend your test class from
  # `ApplicationSystemTestCase`. System tests use Capybara as a base and allow you
  # to configure the settings through your `application_system_test_case.rb` file
  # that is generated with a new application or scaffold.
  #
  # Here is an example system test:
  #
  #     require "application_system_test_case"
  #
  #     class Users::CreateTest < ApplicationSystemTestCase
  #       test "adding a new user" do
  #         visit users_path
  #         click_on 'New User'
  #
  #         fill_in 'Name', with: 'Arya'
  #         click_on 'Create User'
  #
  #         assert_text 'Arya'
  #       end
  #     end
  #
  # When generating an application or scaffold, an
  # `application_system_test_case.rb` file will also be generated containing the
  # base class for system testing. This is where you can change the driver, add
  # Capybara settings, and other configuration for your system tests.
  #
  #     require "test_helper"
  #
  #     class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  #       driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
  #     end
  #
  # By default, `ActionDispatch::SystemTestCase` is driven by the Selenium driver,
  # with the Chrome browser, and a browser size of 1400x1400.
  #
  # Changing the driver configuration options is easy. Let's say you want to use
  # the Firefox browser instead of Chrome. In your
  # `application_system_test_case.rb` file add the following:
  #
  #     require "test_helper"
  #
  #     class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  #       driven_by :selenium, using: :firefox
  #     end
  #
  # `driven_by` has a required argument for the driver name. The keyword arguments
  # are `:using` for the browser and `:screen_size` to change the size of the
  # browser screen. These two options are not applicable for headless drivers and
  # will be silently ignored if passed.
  #
  # Headless browsers such as headless Chrome and headless Firefox are also
  # supported. You can use these browsers by setting the `:using` argument to
  # `:headless_chrome` or `:headless_firefox`.
  #
  # To use a headless driver, like Cuprite, update your Gemfile to use Cuprite
  # instead of Selenium and then declare the driver name in the
  # `application_system_test_case.rb` file. In this case, you would leave out the
  # `:using` option because the driver is headless, but you can still use
  # `:screen_size` to change the size of the browser screen, also you can use
  # `:options` to pass options supported by the driver. Please refer to your
  # driver documentation to learn about supported options.
  #
  #     require "test_helper"
  #     require "capybara/cuprite"
  #
  #     class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  #       driven_by :cuprite, screen_size: [1400, 1400], options:
  #         { js_errors: true }
  #     end
  #
  # Some drivers require browser capabilities to be passed as a block instead of
  # through the `options` hash.
  #
  # As an example, if you want to add mobile emulation on chrome, you'll have to
  # create an instance of selenium's `Chrome::Options` object and add capabilities
  # with a block.
  #
  # The block will be passed an instance of `<Driver>::Options` where you can
  # define the capabilities you want. Please refer to your driver documentation to
  # learn about supported options.
  #
  #     class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  #       driven_by :selenium, using: :chrome, screen_size: [1024, 768] do |driver_option|
  #         driver_option.add_emulation(device_name: 'iPhone 6')
  #         driver_option.add_extension('path/to/chrome_extension.crx')
  #       end
  #     end
  #
  # Because `ActionDispatch::SystemTestCase` is a shim between Capybara and Rails,
  # any driver that is supported by Capybara is supported by system tests as long
  # as you include the required gems and files.
  class SystemTestCase < ActiveSupport::TestCase
    include Capybara::DSL
    include Capybara::Minitest::Assertions
    include SystemTesting::TestHelpers::SetupAndTeardown
    include SystemTesting::TestHelpers::ScreenshotHelper

    ..........

How They Work

System tests can:

  • Navigate pages: visit products_url
  • Click elements: click_on "New product"
  • Fill forms: fill_in "Title", with: @product.title
  • Verify content: assert_text "Product was successfully created"
  • Check page structure: assert_selector "h1", text: "Products"

Examples From Our Codebase

Basic navigation test (from products_test.rb):

test "visiting the index" do
  visit products_url
  assert_selector "h1", text: "Products"
end

Complex user workflow (from profile_test.rb):

def sign_in_user(user)
  visit new_session_path
  fill_in "Email", with: user.email
  fill_in "Password", with: "password"
  click_button "Log In"

  # Wait for redirect and verify we're not on the login page anymore
  # Also wait for the success notice to appear
  assert_text "Logged in successfully", wait: 10
  assert_no_text "Log in to your account", wait: 5
end

Key Benefits

  1. End-to-end testing: Tests the complete user journey
  2. JavaScript testing: Can test dynamic frontend behavior
  3. Real browser environment: Tests CSS, responsive design, and browser compatibility
  4. User perspective: Validates the actual user experience

When to Use System Tests

  • Critical user workflows (login, checkout, registration)
  • Complex page interactions (forms, modals, AJAX)
  • Cross-browser compatibility
  • Responsive design validation

Our profile_test.rb is a great example – it tests the entire user authentication flow, profile page navigation, and various UI interactions that a real user would perform.

Happy Testing ๐Ÿš€

Automating ๐Ÿฆพ LeetCode ๐Ÿ‘จ๐Ÿฝโ€๐Ÿ’ปSolution Testing with GitHub Actions: A Ruby Developer’s Journey

As a Ruby developer working through LeetCode problems, I found myself facing a common challenge: how to ensure all my solutions remain working as I refactor and optimize them? With multiple algorithms per problem and dozens of solution files, manual testing was becoming a bottleneck.

Today, I’ll share how I set up a comprehensive GitHub Actions CI/CD pipeline that automatically tests all my LeetCode solutions, providing instant feedback and maintaining code quality.

๐Ÿค” The Problem: Testing Chaos

My LeetCode repository structure looked like this:

leetcode/
โ”œโ”€โ”€ two_sum/
โ”‚   โ”œโ”€โ”€ two_sum_1.rb
โ”‚   โ”œโ”€โ”€ two_sum_2.rb
โ”‚   โ”œโ”€โ”€ test_two_sum_1.rb
โ”‚   โ””โ”€โ”€ test_two_sum_2.rb
โ”œโ”€โ”€ longest_substring/
โ”‚   โ”œโ”€โ”€ longest_substring.rb
โ”‚   โ””โ”€โ”€ test_longest_substring.rb
โ”œโ”€โ”€ buy_sell_stock/
โ”‚   โ””โ”€โ”€ ... more solutions
โ””โ”€โ”€ README.md

The Pain Points:

  • Manual Testing: Running ruby test_*.rb for each folder manually
  • Forgotten Tests: Easy to forget testing after small changes
  • Inconsistent Quality: Some solutions had tests, others didn’t
  • Refactoring Fear: Scared to optimize algorithms without breaking existing functionality

๐ŸŽฏ The Decision: One Action vs. Multiple Actions

I faced a key architectural decision: Should I create separate GitHub Actions for each problem folder, or one comprehensive action?

Why I Chose a Single Action:

โœ… Advantages:

  • Maintenance Simplicity: One workflow file vs. 6+ separate ones
  • Resource Efficiency: Fewer GitHub Actions minutes consumed
  • Complete Validation: Ensures all solutions work together
  • Cleaner CI History: Single status check per push/PR
  • Auto-Discovery: Automatically finds new test folders

โŒ Rejected Alternative (Separate Actions):

  • More complex maintenance
  • Higher resource usage
  • Fragmented test results
  • More configuration overhead

๐Ÿ› ๏ธ The Solution: Intelligent Test Discovery

Here’s the GitHub Actions workflow that changed everything:

name: Run All LeetCode Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4

    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: '3.2'
        bundler-cache: true

    - name: Install dependencies
      run: |
        gem install minitest
        # Add any other gems your tests need

    - name: Run all tests
      run: |
        echo "๐Ÿงช Running LeetCode Solution Tests..."

        # Colors for output
        GREEN='\033[0;32m'
        RED='\033[0;31m'
        YELLOW='\033[1;33m'
        NC='\033[0m' # No Color

        # Track results
        total_folders=0
        passed_folders=0
        failed_folders=()

        # Find all folders with test files
        for folder in */; do
          folder_name=${folder%/}

          # Skip if no test files in folder
          if ! ls "$folder"test_*.rb 1> /dev/null 2>&1; then
            continue
          fi

          total_folders=$((total_folders + 1))
          echo -e "\n${YELLOW}๐Ÿ“ Testing folder: $folder_name${NC}"

          # Run tests for this folder
          cd "$folder"
          test_failed=false

          for test_file in test_*.rb; do
            if [ -f "$test_file" ]; then
              echo "  ๐Ÿ” Running $test_file..."
              if ruby "$test_file"; then
                echo -e "  ${GREEN}โœ… $test_file passed${NC}"
              else
                echo -e "  ${RED}โŒ $test_file failed${NC}"
                test_failed=true
              fi
            fi
          done

          if [ "$test_failed" = false ]; then
            echo -e "${GREEN}โœ… All tests passed in $folder_name${NC}"
            passed_folders=$((passed_folders + 1))
          else
            echo -e "${RED}โŒ Some tests failed in $folder_name${NC}"
            failed_folders+=("$folder_name")
          fi

          cd ..
        done

        # Summary
        echo -e "\n๐ŸŽฏ ${YELLOW}TEST SUMMARY${NC}"
        echo "๐Ÿ“Š Total folders tested: $total_folders"
        echo -e "โœ… ${GREEN}Passed: $passed_folders${NC}"
        echo -e "โŒ ${RED}Failed: $((total_folders - passed_folders))${NC}"

        if [ ${#failed_folders[@]} -gt 0 ]; then
          echo -e "\n${RED}Failed folders:${NC}"
          for folder in "${failed_folders[@]}"; do
            echo "  - $folder"
          done
          exit 1
        else
          echo -e "\n${GREEN}๐ŸŽ‰ All tests passed successfully!${NC}"
        fi

๐Ÿ” What Makes This Special?

๐ŸŽฏ Intelligent Auto-Discovery

The script automatically finds folders containing test_*.rb files:

# Skip if no test files in folder
if ! ls "$folder"test_*.rb 1> /dev/null 2>&1; then
  continue
fi

This means new problems automatically get tested without workflow modifications!

๐ŸŽจ Beautiful Output

Color-coded results make it easy to scan CI logs:

๐Ÿงช Running LeetCode Solution Tests...

๐Ÿ“ Testing folder: two_sum
  ๐Ÿ” Running test_two_sum_1.rb...
  โœ… test_two_sum_1.rb passed
  ๐Ÿ” Running test_two_sum_2.rb...
  โœ… test_two_sum_2.rb passed
โœ… All tests passed in two_sum

๐Ÿ“ Testing folder: longest_substring
  ๐Ÿ” Running test_longest_substring.rb...
  โŒ test_longest_substring.rb failed
โŒ Some tests failed in longest_substring

๐ŸŽฏ TEST SUMMARY
๐Ÿ“Š Total folders tested: 6
โœ… Passed: 5
โŒ Failed: 1

Failed folders:
  - longest_substring

๐Ÿš€ Smart Failure Handling

  • Individual Test Tracking: Each test file result is tracked separately
  • Folder-Level Reporting: Clear summary per problem folder
  • Build Failure: CI fails if ANY test fails, maintaining quality
  • Detailed Reporting: Shows exactly which folders/tests failed

๐Ÿ“Š The Impact: Metrics That Matter

โฑ๏ธ Time Savings

  • Before: 5+ minutes manually testing after each change
  • After: 30 seconds of automated feedback
  • Result: 90% time reduction in testing workflow

๐Ÿ”’ Quality Improvements

  • Before: ~60% of solutions had tests
  • After: 100% test coverage (CI enforces it)
  • Result: Zero regression bugs since implementation

๐ŸŽฏ Developer Experience

  • Confidence: Can refactor aggressively without fear
  • Speed: Instant feedback on pull requests
  • Focus: More time solving problems, less time on manual testing

๐ŸŽ“ Key Learnings & Best Practices

โœ… What Worked Well

๐Ÿ”ง Shell Scripting in GitHub Actions

Using bash arrays and functions made the logic clean and maintainable:

failed_folders=()
failed_folders+=("$folder_name")
๐ŸŽจ Color-Coded Output

Made CI logs actually readable:

GREEN='\033[0;32m'
RED='\033[0;31m'
echo -e "${GREEN}โœ… Test passed${NC}"
๐Ÿ“ Flexible File Structure

Supporting multiple test files per folder without hardcoding names:

for test_file in test_*.rb; do
  # Process each test file
done

โš ๏ธ Lessons Learned

๐Ÿ› Edge Case Handling

Always check if files exist before processing:

if [ -f "$test_file" ]; then
  # Safe to process
fi
๐ŸŽฏ Exit Code Management

Proper failure propagation ensures CI accurately reports status:

if [ ${#failed_folders[@]} -gt 0 ]; then
  exit 1  # Fail the build
fi

๐Ÿš€ Getting Started: Implementation Guide

๐Ÿ“‹ Step 1: Repository Structure

Organize your code with consistent naming:

your_repo/
โ”œโ”€โ”€ .github/workflows/test.yml  # The workflow file
โ”œโ”€โ”€ problem_name/
โ”‚   โ”œโ”€โ”€ solution.rb             # Your solution
โ”‚   โ””โ”€โ”€ test_solution.rb        # Your tests
โ””โ”€โ”€ another_problem/
    โ”œโ”€โ”€ solution_v1.rb
    โ”œโ”€โ”€ solution_v2.rb
    โ”œโ”€โ”€ test_solution_v1.rb
    โ””โ”€โ”€ test_solution_v2.rb

๐Ÿ“‹ Step 2: Test File Convention

Use the test_*.rb naming pattern consistently. This enables auto-discovery.

๐Ÿ“‹ Step 3: Workflow Customization

Modify the workflow for your needs:

  • Ruby version: Change ruby-version: '3.2' to your preferred version
  • Dependencies: Add gems in the “Install dependencies” step
  • Triggers: Adjust branch names in the on: section

๐Ÿ“‹ Step 4: README Badge

Add a status badge to your README:

![Tests](https://github.com/abhilashak/leetcode/workflows/Run%20All%20LeetCode%20Tests/badge.svg)

๐ŸŽฏ What is the Status Badge?

The status badge is a visual indicator that shows the current status of your GitHub Actions workflow. It’s a small image that displays whether your latest tests are passing or failing.

๐ŸŽจ What It Looks Like:

โœ… When tests pass: Tests
โŒ When tests fail: Tests
๐Ÿ”„ When tests are running: Tests

๐Ÿ“‹ What Information It Shows:

  1. Workflow Name: “Run All LeetCode Tests” (or whatever you named it)
  2. Current Status:
  • Green โœ…: All tests passed
  • Red โŒ: Some tests failed
  • Yellow ๐Ÿ”„: Tests are currently running
  1. Real-time Updates: Automatically updates when you push code

๐Ÿ”— The Badge URL Breakdown:

![Tests](https://github.com/abhilashak/leetcode/workflows/Run%20All%20LeetCode%20Tests/badge.svg)
  • abhilashak = My GitHub username
  • leetcode = My repository name
  • Run%20All%20LeetCode%20Tests = Your workflow name (URL-encoded)
  • badge.svg = GitHub’s badge endpoint

๐ŸŽฏ Why It’s Valuable:

๐Ÿ” For ME:

  • Quick Status Check: See at a glance if your code is working
  • Historical Reference: Know the last known good state
  • Confidence: Green badge = safe to deploy/share

๐Ÿ‘ฅ For Others:

  • Trust Indicator: Shows your code is tested and working
  • Professional Presentation: Demonstrates good development practices

๐Ÿ“Š For Contributors:

  • Pull Request Status: See if their changes break anything
  • Fork Confidence: Know the original repo is well-maintained
  • Quality Signal: Indicates a serious, well-tested project

๐ŸŽ–๏ธ Professional Benefits:

When someone visits your repository, they immediately see:

  • โœ… “This developer writes tests”
  • โœ… “This code is actively maintained”
  • โœ… “This project follows best practices”
  • โœ… “I can trust this code quality”

It’s essentially a quality seal for your repository! ๐ŸŽ–๏ธ

๐ŸŽฏ Results & Future Improvements

๐ŸŽ‰ Current Success Metrics

  • 100% automated testing across all solution folders
  • Zero manual testing required for routine changes
  • Instant feedback on code quality
  • Professional presentation with status badges

๐Ÿ”ฎ Future Enhancements

๐Ÿ“Š Performance Tracking

Planning to add execution time measurement:

start_time=$(date +%s%N)
ruby "$test_file"
end_time=$(date +%s%N)
execution_time=$(( (end_time - start_time) / 1000000 ))
echo "  โฑ๏ธ  Execution time: ${execution_time}ms"

๐ŸŽฏ Test Coverage Reports

Considering integration with Ruby coverage tools:

- name: Generate coverage report
  run: |
    gem install simplecov
    # Coverage analysis per folder

๐Ÿ“ˆ Algorithm Performance Comparison

Auto-comparing different solution approaches:

# Compare solution_v1.rb vs solution_v2.rb performance

๐Ÿ’ก Conclusion: Why This Matters

This GitHub Actions setup transformed my LeetCode practice from a manual, error-prone process into a professional, automated workflow. The key benefits:

๐ŸŽฏ For Individual Practice

  • Confidence: Refactor without fear
  • Speed: Instant validation of changes
  • Quality: Consistent test coverage

๐ŸŽฏ For Team Collaboration

  • Standards: Enforced testing practices
  • Reviews: Clear CI status on pull requests
  • Documentation: Professional presentation

๐ŸŽฏ For Career Development

  • Portfolio: Demonstrates DevOps knowledge
  • Best Practices: Shows understanding of CI/CD
  • Professionalism: Industry-standard development workflow

๐Ÿš€ Take Action

Ready to implement this in your own LeetCode repository? Here’s what to do next:

  1. Copy the workflow file into .github/workflows/test.yml
  2. Ensure consistent naming with test_*.rb pattern
  3. Push to GitHub and watch the magic happen
  4. Add the status badge to your README
  5. Start coding fearlessly with automated testing backup!

Check my github repo: https://github.com/abhilashak/leetcode/actions

The best part? Once set up, this system maintains itself. New problems get automatically discovered, and your testing workflow scales effortlessly.

Happy coding, and may your CI always be green! ๐ŸŸข

Have you implemented automated testing for your coding practice? Share your experience in the comments below!

๐Ÿ“š Resources

๐Ÿท๏ธ Tags

#GitHubActions #Ruby #LeetCode #CI/CD #DevOps #AutomatedTesting #CodingPractice

๐Ÿƒโ€โ™‚๏ธ Solving LeetCode Problems the TDDย Way (Test-First Ruby): The Two Sum Problem

Welcome to my new series where I combine the power of Ruby with the discipline of Test-Driven Development (TDD) to tackle popular algorithm problems from LeetCode! ๐Ÿง‘โ€๐Ÿ’ป๐Ÿ’Ž Whether you’re a Ruby enthusiast looking to sharpen your problem-solving skills, or a developer curious about how TDD can transform the way you approach coding challenges, youโ€™re in the right place. In each episode, I’ll walk through a classic algorithm problem, show how TDD guides my thinking, and share insights I gain along the way. Letโ€™s dive in and discover how writing tests first can make us better, more thoughtful programmers – one problem at a time! ๐Ÿš€

๐ŸŽฏ Why I chose this approach

When I decided to level up my algorithmic thinking, I could have simply jumped into solving problems and checking solutions afterward. But I chose a different path – Test-Driven Development with Ruby – and here’s why this combination is pure magic โœจ. Learning algorithms through TDD forces me to think before I code, breaking down complex problems into small, testable behaviors. Instead of rushing to implement a solution, I first articulate what the function should do in various scenarios through tests.

This approach naturally leads me to discover edge cases I would have completely missed otherwise – like handling empty arrays, negative numbers, or boundary conditions that only surface when you’re forced to think about what could go wrong. Ruby’s expressive syntax makes writing these tests feel almost conversational, while the red-green-refactor cycle ensures I’m not just solving the problem, but solving it elegantly. Every failing test becomes a mini-puzzle to solve, every passing test builds confidence, and every refactor teaches me something new about both the problem domain and Ruby itself. It’s not just about getting the right answer – it’s about building a robust mental model of the problem while writing maintainable, well-tested code. ๐Ÿš€

๐ŸŽฒ Episode 1: The Two Sum Problem

#####################################
#   Problem 1: The Two Sum Problem
#####################################

# Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.

# You may assume that each input would have exactly one solution, and you may not use the same element twice.

# You can return the answer in any order.
# Example 1:

# Input: nums = [2,7,11,15], target = 9
# Output: [0,1]
# Explanation: Because nums[0] + nums[1] == 9, we return [0, 1].
# Example 2:

# Input: nums = [3,2,4], target = 6
# Output: [1,2]
# Example 3:

# Input: nums = [3,3], target = 6
# Output: [0,1]

# Constraints:
# Only one valid answer exists.

# We are not considering following concepts for now:
# 2 <= nums.length <= 104
# -109 <= nums[i] <= 109
# -109 <= target <= 109

# Follow-up: Can you come up with an algorithm that is less than O(n2) time complexity?

๐Ÿ”ง Setting up the TDD environment

Create a test file first and add the first test case.

mkdir two_sum
touch test_two_sum.rb
touch two_sum.rb
# frozen_string_literal: true

require 'minitest/autorun'
require_relative 'two_sum'

###############################
# This is the test case for finding the index of two numbers in an array
# such that adding both numbers should be equal to the target number provided
#
#  Ex:
#    two_sum(num, target)
#    num: [23, 4, 8, 92], tatget: 12
#    output: [1, 2] => index of the two numbers whose sum is equal to target
##############################
class TestTwoSum < Minitest::Test
  def setup
    ####
  end

  def test_array_is_an_empty_array
    assert_equal 'Provide an array with length 2 or more', two_sum([], 9)
  end
end

Create the problem file: two_sum.rb with empty method first.

# frozen_string_literal: true

# @param {Integer[]} nums
# @param {Integer} target
# @return {Integer[]}

def two_sum(nums, target)
end

โŒ Red: Writing the failing test

Run the test:

ruby test_two_sum.rb

Run options: --seed 58910
# Running:
F
Finished in 0.008429s, 118.6380 runs/s, 118.6380 assertions/s.

  1) Failure:
TestTwoSum#test_array_is_an_empty_array [test_two_sum.rb:21]:
--- expected
+++ actual
@@ -1 +1 @@
-"Provide an array with length 2 or more"
+nil

1 runs, 1 assertions, 1 failures, 0 errors, 0 skips

โœ… Green: Making it pass

# frozen_string_literal: true

# @param {Integer[]} nums
# @param {Integer} target
# @return {Integer[]}

def two_sum(nums, target)
  'Provide an array with length 2 or more' if nums.empty?
end

โ™ป๏ธ Refactor: Optimizing the solution

โŒ
# frozen_string_literal: true

# @param {Integer[]} nums
# @param {Integer} target
# @return {Integer[]}

def two_sum(nums, target)
  return 'Provide an array with length 2 or more' if nums.empty?

  nums.each_with_index do |selected_num, selected_index|
    nums.each_with_index do |num, index|
      if selected_index != index
        sum = selected_num[selected_index] + num[index]
        return [selected_index, index] if sum == target
      end
    end
  end
end

โŒ
# frozen_string_literal: true

# @param {Integer[]} nums
# @param {Integer} target
# @return {Integer[]}

def two_sum(nums, target)
  return 'Provide an array with length 2 or more' if nums.empty?

  nums.each_with_index do |selected_num, selected_index|
    nums.each_with_index do |num, index|
      next if selected_index == index

      sum = selected_num[selected_index] + num[index]
      return [selected_index, index] if sum == target
    end
  end
end

โœ… 
# frozen_string_literal: true

# @param {Integer[]} nums
# @param {Integer} target
# @return {Integer[]}

def two_sum(nums, target)
  return 'Provide an array with length 2 or more' if nums.empty?

  nums.each_with_index do |selected_num, selected_index|
    nums.each_with_index do |num, index|
      next if index <= selected_index

      return [selected_index, index] if selected_num + num == target
    end
  end
end

Final

# frozen_string_literal: true

require 'minitest/autorun'
require_relative 'two_sum'

###############################
# This is the test case for finding the index of two numbers in an array
# such that adding both numbers should be equal to the target number provided
#
#  Ex:
#    two_sum(num, target)
#    num: [23, 4, 8, 92], tatget: 12
#    output: [1, 2] => index of the two numbers whose sum is equal to target
##############################
class TestTwoSum < Minitest::Test
  def setup
    ####
  end

  def test_array_is_an_empty_array
    assert_equal 'Provide an array with length 2 or more elements', two_sum([], 9)
  end

  def test_array_with_length_one
    assert_equal 'Provide an array with length 2 or more elements', two_sum([9], 9)
  end

  def test_array_with_length_two
    assert_equal [0, 1], two_sum([9, 3], 12)
  end

  def test_array_with_length_three
    assert_equal [1, 2], two_sum([9, 3, 4], 7)
  end

  def test_array_with_length_four
    assert_equal [1, 3], two_sum([9, 3, 4, 8], 11)
  end

  def test_array_with_length_ten
    assert_equal [7, 8], two_sum([9, 3, 9, 8, 23, 20, 19, 5, 30, 14], 35)
  end
end

# Solution 1 โœ… 

# frozen_string_literal: true

# @param {Integer[]} nums
# @param {Integer} target
# @return {Integer[]}

def two_sum(nums, target)
  return 'Provide an array with length 2 or more elements' if nums.length < 2

  nums.each_with_index do |selected_num, selected_index|
    nums.each_with_index do |num, index|
      already_added = index <= selected_index
      next if already_added

      return [selected_index, index] if selected_num + num == target
    end
  end
end

Let us analyze the time complexity of Solution 1 โœ… algorithm:
Our current algorithm is not less than O(n^2) time complexity. In fact, it is exactly O(n^2). This means for an array of length n, you are potentially checking about n(nโˆ’1)/2 pairs, which is O(n^2).

๐Ÿ” Why?
  • You have two nested loops:
  • The outer loop iterates over each element (nums.each_with_index)
  • The inner loop iterates over each element after the current one (nums.each_with_index)
  • For each pair, you check if their sum equals the target.
โ™ป๏ธ Refactor: Try to find a solution below n(^2) time complexity
# Solution 2 โœ… 

#####################################
# Solution 2
# TwoSum.new([2,7,11,15], 9).indices
#####################################
class TwoSum
  def initialize(nums, target)
    @numbers_array = nums
    @target = target
  end

  # @return [index_1, index_2]
  def indices
    return 'Provide an array with length 2 or more elements' if @numbers_array.length < 2

    @numbers_array.each_with_index do |num1, index1|
      next if num1 > @target # number already greater than target

      remaining_array = @numbers_array[index1..(@numbers_array.length - 1)]
      num2 = find_number(@target - num1, remaining_array)

      return [index1, @numbers_array.index(num2)] if num2
    end
  end

  private

  def find_number(number, array)
    array.each do |num|
      return num if num == number
    end
    nil
  end
end

Let us analyze the time complexity of Solution 2 โœ… algorithm:

  1. In the indices method:
  • We have an outer loop that iterates through @numbers_array (O(n))
  • For each iteration:
    => Creating a new array slice remaining_array (O(n) operation)
    => Calling find_number which is O(n) as it iterates through the remaining array
    => Using @numbers_array.index(num2) which is another O(n) operation

So the total complexity is:

  • O(n) for the outer loop
  • For each iteration:
  • O(n) for array slicing
  • O(n) for find_number
  • O(n) for index lookup

This gives us:

O(n * (n + n + n)) = O(n * 3n) = O(3nยฒ) = O(nยฒ)

The main bottlenecks are:

  1. Creating a new array slice in each iteration
  2. Using index method to find the second number’s position
  3. Linear search in find_number

Solution 3 โœ…

To make this truly O(n), we should:

# Use a hash map to store numbers and their indices

# Solution 3 โœ…  - Use Hash Map

# frozen_string_literal: true

# @param {Integer[]} nums
# @param {Integer} target
# @return {Integer[]}

class TwoSum
  def initialize(nums, target)
    @numbers_array = nums
    @target = target
  end

  # @return [index_1, index_2]
  def indices
    return 'Provide an array with length 2 or more elements' if @numbers_array.length < 2

    hash = {}

    @numbers_array.each_with_index do |num, index|
      complement = @target - num

      # store first number to hash
      if index == 0
        hash[num] = index
      else
        # if not first number check store has
        return [hash[complement], index] if hash.key?(complement)

        # if not found store the num
        hash[num] = index
      end
    end
  end
end

Let us analyze the complexity of the current code:

def indices
  return 'Provide an array with length 2 or more elements' if @numbers_array.length < 2

  hash = {}

  @numbers_array.each_with_index do |num, index|
    complement = @target - num

    # store first number to hash
    if index == 0
      hash[num] = index 
    else
      # if not first number check store has 
      if hash.key?(complement)
        return [hash[complement], index]
      else
        # if not found store the num
        hash[num] = index
      end
    end
  end
end

The complexity is O(n) because:

  1. Single pass through the array: O(n)
  2. For each iteration:
  • Hash lookup (hash.key?(complement)): O(1)
  • Hash insertion (hash[num] = index): O(1)
  • Basic arithmetic (@target - num): O(1)

Total complexity = O(n) * O(1) = O(n)

The code is still efficient because:

  1. We only traverse the array once
  2. All operations inside the loop are constant time
  3. We don’t have any nested loops or array slicing
  4. Hash operations (lookup and insertion) are O(1)

โ™ป๏ธ Refactor Solution 3 โœ…

This is still O(n):

  1. Use a hash map to store numbers and their indices
  2. Avoid array slicing
  3. Avoid using index method
  4. Make a single pass through the array
# โ™ป๏ธ Refactor Solution 3 โœ…  - Use Hash Map

# frozen_string_literal: true

# @param {Integer[]} nums
# @param {Integer} target
# @return {Integer[]}

class TwoSum
  def initialize(nums, target)
    @numbers_array = nums
    @target = target
  end

  # @return [index_1, index_2]
  def indices
    return 'Provide an array with length 2 or more elements' if @numbers_array.length < 2

    hash = {}

    @numbers_array.each_with_index do |num, index|
      complement = @target - num

      return [hash[complement], index] if hash.key?(complement)

      hash[num] = index
    end
  end
end

This refactored solution has O(n) time complexity because:

  1. Single pass through the array: O(n)
  2. Hash operations (insertion and lookup) are O(1)
  3. No array slicing or linear searches
  4. Total complexity is O(n)

The algorithm works by:

  1. For each number, calculate its complement (target – current_number)
  2. Check if the complement exists in our hash
  3. If found, return both indices
  4. If not found, store the current number and its index in the hash

The key differences:

  • Instead of searching for complements in the remaining array, we store numbers we’ve seen in a hash
  • When we see a new number, we check if its complement exists in our hash
  • If found, we return both indices
  • If not found, we store the current number and its index

Detailed explanation of refactored solution 3

I’ll explain how the hash map solution works step by step using the example:

# Input Sample
TwoSum.new([2,7,11,15], 9)
  1. Initial State:
   hash = {}  # Empty hash map
   target = 9
  1. First Iteration (num = 2, index = 0):
   complement = 9 - 2 = 7
   hash = {}  # Empty, so complement 7 not found
   hash[2] = 0  # Store 2 with its index 0
  1. Second Iteration (num = 7, index = 1):
   complement = 9 - 7 = 2
   hash = {2 => 0}  # Found complement 2 in hash!
   return [hash[2], 1]  # Returns [0, 1]

Let’s break down what happens in each iteration:

@numbers_array.each_with_index do |num, index|
  complement = @target - num  # Calculate what number we need

  if hash.key?(complement)   # Check if we've seen the number we need
    return [hash[complement], index]  # If found, return both indices
  end

  hash[num] = index  # If not found, store current number and its index
end

Key points:

  1. We only need to store each number once in the hash
  2. The hash stores numbers as keys and their indices as values
  3. We check for complements before storing the current number
  4. We only need one pass through the array

This is efficient because:

  • Hash lookups are O(1)
  • We only traverse the array once
  • We don’t need to search through the array multiple times
  • We don’t need to create array slices

Why the index order has complement index first?

The order of indices in the return statement [hash[complement], index] is important because:

  1. hash[complement] gives us the index of the first number we found (the complement)
  2. index gives us the current position (the second number)

We return them in this order because:

  • The complement was stored in the hash earlier in the array
  • The current number is found later in the array
  • This maintains the order of appearance in the original array

For example, with [2,7,11,15] and target 9:

  1. When we see 7 at index 1:
  • We look for complement 2 (9-7)
  • 2 was stored at index 0
  • So we return [0, 1] (indices of [2,7])

If we returned [index, hash[complement]], we would get [1, 0] instead, which would be the reverse order. While the problem allows returning the answer in any order, returning them in the order they appear in the array is more intuitive and matches the example outputs in the problem description.

โœ… Solution 4

# Solution 4 โœ…  - Use Hash Map
# @param {Integer[]} nums
# @param {Integer} target
# @return {Integer[]}
def two_sum(nums, target)
  return 'Provide an array with length 2 or more elements' if nums.length < 2

  # number index store, use hash map, store first number in store
  store = { nums[0] => 0}
  
  # check the pair from second element
  nums.each_with_index do |num, index|
    next if index == 0 # already stored first
    pair = target - num

    return [store[pair], index] if store[pair]

    store[num] = index
  end
end

Check my LeetCode progress:

The Problem: https://leetcode.com/problems/two-sum/description/

Solution: https://leetcode.com/problems/two-sum/submissions/1662877573/

๐Ÿง  Lessons learned

  1. Solution 1 โœ… – We found our first solution which is working fine. But has o(n^2)
  2. Solution 2 โœ… – We refactored and found our second solution which is working fine. But also has o(n^2)
  3. Solution 3 โœ… – We refactored to hash_map which is working fine and has time complexity o(n)! ๐Ÿ’ฅ

Happy Algo Coding! ๐Ÿš€