Adding tests to an existing project

05 Jan 2023
Security For Rails Developers

Develop the right mindset for Rails security

Avoid shipping vulnerable code by learning how to prevent security issues in your Rails applications.

Get the course for $99

I created a little gem recently to help me automate server provisioning. Because it was just a prototype, and I didn’t know what the result would be, I haven’t written a single test for the gem, but now, it reached a point where others might use it and contribute to it, so having a test suite would be a great help, so I need to start adding tests, and I’d like to share how I approach this task.

Since the gem is already working, I will go from high-level to low-level tests. I am in a lucky position because since I wrote this code, I know how it works, but if it were someone else’s work, I would find the entry point of it and start adding tests from there.

Prepper is a pretty simple tool, I built it on top of SSHKit, and it has a command line interface, which creates an instance of the Prepper::Runner class with the provided configuration, which is ruby code, evaluates that code on the instance, and calls the run method:

module Prepper
  class Runner

    attr_accessor :host, :packages, :commands, :user, :port

    def self.new(config)
      runner = new
      runner.new
      runner
    end

    def initialize(config)
      @packages = []
      @commands = []
      @user = "root"
      @port = 22
      instance_eval config
    end
    ...
  end
end

First, let’s write a test to verify that this method successfully executes. I need to create a test file in test/prepper/runner_test.rb:

require "test_helper"

class RunnerTest < Minitest::Test
  def test_it_runs
    assert Prepper::Runner.new("")
  end
end

This test passes, so let’s write tests for each config setter method of the runner:

  def test_it_sets_host
    code = <<-CODE
    server_host "test.com"
    CODE
    runner = Prepper::Runner.new(code)
    assert_equal("test.com", runner.host)
  end

  def test_it_sets_user
    code = <<-CODE
    server_user "ubuntu"
    CODE
    runner = Prepper::Runner.new(code)
    assert_equal('ubuntu', runner.user)
  end

  def test_it_sets_port
    code = <<-CODE
    server_port 999
    CODE
    runner = Prepper::Runner.new(code)
    assert_equal(999, runner.port)
  end

  def test_it_sets_ssh_options
    code = <<-CODE
    ssh_options({ forward_agent: false })
    CODE
    runner = Prepper::Runner.new(code)
    assert_equal({forward_agent: false}, runner.instance_variable_get("@ssh_options"))
  end

There is also a server_hash method, which is passed to SSHKit, and built from the result of the above setter methods. Let’s write a test to make sure it is set correctly:

  def test_server_hash
    code = <<-CODE
      server_host "test.com"
      server_port 999
      server_user "ubuntu"
      ssh_options({ forward_agent: false })
    CODE
    runner = Prepper::Runner.new(code)

    assert_equal(
      {
        hostname: 'test.com',
        user: 'ubuntu',
        port: 999,
        ssh_options: {forward_agent: false}
      },
      runner.server_hash
    )
  end

We are making progress! Let’s see what else is going on in the runner. Let’s write a simple test for add_command first:

  def add_command(command, opts = {})
    package = Package.new("base", opts)
    package.runner = self
    opts[:user] ||= "root"
    opts[:within] ||= "/"
    package.commands << Command.new(command, opts)
    @packages << package
  end

If we look at the method definition, we can see it creates a package with the name “base” and forwards the options, then it adds a command to the package with the command string passed, and sets the user in the command’s options to “root”, and the within to “/” by default, so we will test that these happen

  def test_add_command_adds_a_package_with_a_command
    code = <<-CODE
      add_command "ls /"
    CODE
    runner = Prepper::Runner.new(code)
    refute_empty runner.packages
    assert_equal 1, runner.packages.size
    assert_equal 'base', runner.packages.first.name
    package_options = runner.packages.first.instance_variable_get("@opts")
    assert_equal 'root', package_options[:user]
    assert_equal '/', package_options[:within]
  end

This test passes, but we should also verify that we can override user and within:

  def test_add_command_can_override_user_and_within
    code = <<-CODE
      add_command "ls", user: "ubuntu", within: "/home/ubuntu"
    CODE
    runner = Prepper::Runner.new(code)
    package_options = runner.packages.first.instance_variable_get("@opts")
    assert_equal 'ubuntu', package_options[:user]
    assert_equal '/home/ubuntu', package_options[:within]
  end

And now, we can test the package method:

  def package(name, opts = {}, &block)
    @packages << Package.new(name, opts.merge(runner: self), &block)
  end

It creates a package with the given name, passes the block, and adds it to the list of packages. We can write a simple test to verify the package is created with the correct name; the command we create in the block is added and added to the list of packages, and the runner of the package is set:

  def test_package_registers_a_package
    code = <<-CODE
      package "list root" do
        add_command "ls /"
      end
    CODE
    runner = Prepper::Runner.new(code)
    assert_equal 1, runner.packages.size
    assert_equal "list root", runner.packages.first.name
    assert_equal 1, runner.packages.first.commands.size
    assert_equal runner, runner.packages.first.runner
  end

Now we have some high-level tests for the core of the gem, so we will start adding tests for the Command and Package classes and the various “tools” the gem has.

I might cover that in the following article. Thanks for your attention. I hope you enjoyed the article.

Or follow me on Twitter

Related posts