Steel Vellum, part 4:

Modules in D&D, modules in Ruby

We left our project with an integration test that doesn’t pass (yet), and a unit test for the first piece of business logic revealed by this integration test: the character races as “types” for our Character objects. It is now time to make the unit test pass, which should drive us to an implementation of the Race class, and then move on to the next encounter in our integration test.

Ancestry shenanigans

Our unit test, at the moment, looks like this:

require_relative "test_helper"
require "./lib/steel_vellum/character_creation"
require "./lib/steel_vellum/race"

module SteelVellum
  class CharacterCreationTest < Minitest::Test
    def test_choosing_a_race
      creation = CharacterCreation.new
      race     = Race.new
      
      creation.choose_race race
      
      character = creation.character
      assert_kind_of race, character
    end
  end
end
test/character_creation_test.rb

And, as expected, it fails. More precisely, once we add a placeholder Race class to satisfy the require instruction, it fails with this interesting error: class or module required.

module SteelVellum
  class Race
  end
end
lib/steel_vellum/race.rb
𝄢 ruby -Ilib test/character_creation_test.rb

# Running tests with run options --seed 26591:

E

Finished tests in 0.000418s, 2392.3449 tests/s, 0.0000 assertions/s.


Error:
SteelVellum::CharacterCreationTest#test_choosing_a_race:
TypeError: class or module required
    test/character_creation_test.rb:13:in `test_choosing_a_race'

1 tests, 0 assertions, 0 failures, 1 errors, 0 skips

Back in the previous chapter, I said that a true mockist would probably not use the Race class in the example, but instead something simpler, like Object. Well, this wasn’t completely accurate. Because we want whatever object is passed to #choose_race to define what a Character object “is”, this object has to be either a class or a module, as the error message tells us.

Let’s pause for a second. Our test is driving our design. Writing it, we discovered that we want characters races to be instances of some kind of Race class (race=Race.new). But, at the same time, our test lead us to a design where these instances must be classes or modules themselves (assert_kind_of race, with assert_kind_of expecting a class or a module). Can an instance also be a class (or a module)?

Of course it can! This is one of the great (and elegant, and almost magical) things about Ruby: classes are instances, too – namely, instances of the Class class. By the same principle, modules are instances of the Module class, or a subclass of it. Therefore, we can move on to the next failure in our test by ensuring that Race.new returns either a class or a module. The simplest way to do that would to make Race inherit from either one – except that Ruby doesn’t allow subclassing Class, so the only option left is to have Race inherit from Module, so that Race.new returns a (new) module.

module SteelVellum
  class Race < Module
  end
end
lib/steel_vellum/race.rb
𝄢 ruby -Ilib test/character_creation_test.rb
  
# Running tests with run options --seed 60990:

F

Finished tests in 0.000311s, 3215.4337 tests/s, 3215.4337 assertions/s.


Failure:
SteelVellum::CharacterCreationTest#test_choosing_a_race [test/character_creation_test.rb:13]
Minitest::Assertion: Expected #<SteelVellum::Character:0x0000000103d0a490> to be a kind of #<SteelVellum::Race:0x00000001039a0700>, not SteelVellum::Character.

1 tests, 1 assertions, 1 failures, 0 errors, 0 skips

We’ve successfully failed – meaning that we’ve successfully moved on to a different failure. But this one is a bit cryptic. And how could our object be both “a kind of” Character and “a kind of” Race? Object in Ruby can only be of a single class, right?

Without diving too deep in the (marvellous) object model of Ruby, let’s make a slight detour. The assert_kind_of matcher relies on Object#kind_of?, which is defined like so:

kind_of?(class) → true or false

Returns true if class is the class of obj, or if class is one of the superclasses of obj or modules included in obj.

This is very accurate but maybe a bit obscure, if you’re not familiar with the way classes, modules and instances work in Ruby. Another way to define kind_of? could be:

Returns true is class is among the ancestors of obj’s [singleton] class.

Let’s ignore the word in brackets for now. In Ruby, we know that each object has a class; this class, like all classes, inherits from another class, which itself inherits from another class, and so on until this chain of ancestors reaches BasicObject, “the parent class of all classes in Ruby”. We can check this out by looking at the ancestors of the class of the character object in our test. We know that this object’s class is SteelVellum::Character, so we can do it like this:

𝄢 ruby -I lib -r steel_vellum/character_creation.rb -e "print SteelVellum::Character.ancestors"
[SteelVellum::Character, Object, Kernel, BasicObject]

Ignoring the first item in this array (which is the interrogated class itself), we see the list of classes1 from which Character inherits: Object, Kernel and, eventually, BasicObject. For our test to pass, and our assertion assert_kind_of race, character.character to be true, we need to somehow add race to this list of ancestors.

We cannot do that by making our race object a parent of the Character class – first because it would make no sense from a business logic perspective (characters are not character races), but more importantly because we’ve already established that race is a Module, and modules cannot be inherited from.

However, like classes, Ruby modules can be part of the ancestors chain of a class – in fact, in the ancestors list above, Kernel is actually a module, not a class. As explained in the definition of Object#kind_of?, included modules also count as ancestors. But how could we include this race module in the class of our character object?

A single-use class

Once again, the character object is an instance of Character. So, a naive way to have it also be “a kind of” race would be to call Character.include race. But then, all instances of Character would also have race in their ancestors. All characters would be of the same race, which is not what we want.2

What we want is for this race module to be included in the class of our character instance, but only for this instance. And we can do that thanks to Module#extend and the elegant magic of the singleton class.

By extending the instance with the module instead of including the module in the class, we’ll have what we want. To see this in action, let’s temporarily hack our test:

def test_choosing_a_race
  creation = CharacterCreation.new
  race     = Race.new
  
  creation.choose_race race
  
  character = creation.character
  character.extend race
  
  assert_kind_of race, character
end
test/character_creation_test.rb (excerpt)
𝄢 ruby -Ilib test/character_creation_test.rb

# Running tests with run options --seed 19004:

.

Finished tests in 0.000345s, 2898.5521 tests/s, 2898.5521 assertions/s.


1 tests, 1 assertions, 0 failures, 0 errors, 0 skips

Or test passes! But how come?

When we called character.extend race, Ruby did something clever. It created a new class, anonymous, and had it inherit from Character. It also included race into this new class, and then had character inherit from it. Because it inherits from Character, this anonymous class behaves exactly as Character, but it is specific to the character object. (And it includes race, which is the whole point.)

There is no formal name for this kind of object-specific, anonymous class. Some used to call it “eigenclass”, others “ghost class”, but nowadays, it is most often named singleton class3. In fact, this class can be reached (and created on-the-fly, if necessary) by calling Object#singleton_class. Let’s launch an IRB console and compare the ancestors of this singleton class, for a given Character instance, before and after extending a Race module:

>> Dir[__dir__ + "/lib/steel_vellum/**/*.rb"].each { |f| require f }; include SteelVellum;
>> 
>> c = Character.new
=> #<SteelVellum::Character:0x0000000109f1e320>
>> c.singleton_class.ancestors
=> 
[#<Class:#<SteelVellum::Character:0x0000000109f1e320>>,
 SteelVellum::Character,
 Object,
 SteelVellum,
 Kernel,
 BasicObject]
>> c.extend Race.new
=> #<SteelVellum::Character:0x0000000109f1e320>
>> c.singleton_class.ancestors
=> 
[#<Class:#<SteelVellum::Character:0x0000000109f1e320>>,
 #<SteelVellum::Race:0x000000010abb3860>,
 SteelVellum::Character,
 Object,
 SteelVellum,
 Kernel,
 BasicObject]

So, there you have it. Even though Ruby objects can only be instances of a single class, they can inherit traits from any number of modules, and don’t have to share these inheritances with any other object, thanks to the existence of a singleton class.

Now that we know how to have characters be of a given race, and why this is even possible in the first place, let’s remove the hack from our test and implement things properly:

require_relative "character"

module SteelVellum
  class CharacterCreation
    def choose_race(race)
      @race = race
    end
    
    def character
      Character.new.tap do |c|
        c.extend @race
      end
    end
  end
end
lib/steel_vellum/character_creation.rb
require_relative "test_helper"
require "./lib/steel_vellum/character_creation"
require "./lib/steel_vellum/race"

module SteelVellum
  class CharacterCreationTest < Minitest::Test
    def test_choosing_a_race
      creation = CharacterCreation.new
      race     = Race.new
      
      creation.choose_race race
      
      character = creation.character
      assert_kind_of race, character
    end
  end
end
test/character_creation_test.rb
𝄢 ruby -Ilib test/character_creation_test.rb

# Running tests with run options --seed 60242:

.

Finished tests in 0.000328s, 3048.7789 tests/s, 3048.7789 assertions/s.


1 tests, 1 assertions, 0 failures, 0 errors, 0 skips

Back to the outer loop…

Our unit test now passes – we’ve closed the small loop. Let’s go back to the big loop (the integration test) and see where the next failure leads us.

𝄢 ruby -Ilib test/creating_bruenor_test.rb

# Running tests with run options --seed 19079:

E

Finished tests in 0.000429s, 2331.0023 tests/s, 0.0000 assertions/s.


Error:
SteelVellum::CreatingBruenorTest#test_1_choose_a_race:
TypeError: wrong argument type Class (expected Module)
    /Users/ronan/Dev/steel_vellum/lib/steel_vellum/character_creation.rb:14:in `extend'
    /Users/ronan/Dev/steel_vellum/lib/steel_vellum/character_creation.rb:14:in `block in character'
    <internal:kernel>:90:in `tap'
    /Users/ronan/Dev/steel_vellum/lib/steel_vellum/character_creation.rb:13:in `character'
    test/creating_bruenor_test.rb:17:in `test_1_choose_a_race'

1 tests, 0 assertions, 0 failures, 1 errors, 0 skips

This was to be expected – CharacterCreation#choose_race must be passed a module now, but MountainDwarf is still a slimed class. Let’s change that.

require_relative "../race"

module SteelVellum
  module Races
    MountainDwarf = Race.new
  end
end
lib/steel_vellum/races/mountain_dwarf.rb

Moving on, we can rerun the integration test and figure out what other missing piece of our library we should build now.

𝄢 ruby -Ilib test/creating_bruenor_test.rb

# Running tests with run options --seed 52522:

F

Finished tests in 0.000367s, 2724.7956 tests/s, 5449.5913 assertions/s.


Failure:
SteelVellum::CreatingBruenorTest#test_1_choose_a_race [test/creating_bruenor_test.rb:19]
Minitest::Assertion: Expected: :medium
  Actual: nil

1 tests, 0 assertions, 0 failures, 1 errors, 0 skips

This is a more interesting failure! According to the test, making Bruenor a Mountain Dwarf should automatically give him a :medium size, but at the moment, Character#size always returns nil (since we didn’t bother actually implementing the method’s body). Let’s remedy that.

Because this failure reveals a missing piece of business logic, we must start a new small loop, and design the implementation of this unitary feature through one or more unit tests.

Hooks in you

For a start, let’s simply isolate the failing assertion from the integration test into an unit test:

require_relative "../test_helper"
require "./lib/steel_vellum/character"
require "./lib/steel_vellum/races/mountain_dwarf"

module SteelVellum
  class Races::MountainDwarfTest < Minitest::Test
    def test_a_mountain_dwarf_character_has_a_medium_size
      character = Character.new
      
      assert_nil character.size
      character.extend Races::MountainDwarf
      
      assert_equal :medium, character.size
    end
  end
end
test/races/mountain_dwarf_test.rb

Covered by our unit test, let’s think about a way to make it pass. The test tells us that, once a Character instance is extended by the MountainDwarf module, its #size method should return :medium instead of nil. When a module extends an object, the methods defined inside this module are added to the instance methods of the object’s singleton class, so one way to make our test pass would be to redefine #size in the MountainDwarf module:

require_relative "../race"
  
module SteelVellum
  module Races
    MountainDwarf = Race.new do
      def size
        25
      end
    end
  end
end
lib/steel_vellum/races/mountain_dwarf.rb

However, while perfectly fine in general, I’m not too fond of this approach in this specific situation. That is because a character’s size is more data than behavior. I’d rather store this information in an instance variable than have it being returned by a method4.

Thankfully, Ruby gives us another trick to reach our goals: hook methods. These are methods that, if defined, get called we certain events happen in an object’s lifetime. For example, #method_missing is a well-known hook method that is called when an object (or rather: a module or a class) receives a call to a method that neither it not any of its ancestors define. In our case, we’ll make use of the #extended hook method.

This method is called whenever a module extends an object. We can use it to change the value of the character’s @size instance variable – in practice, giving it a default value, which the Character instance will then be free to change, if need be. (After all, our Dwarf could one day drink a magical potion and grow a size or two.) This is what using the #extended hook looks like:

require_relative "../race"

module SteelVellum
  module Races
    MountainDwarf = Race.new do
      def self.extended(character)
        character.size = :medium
      end
    end
  end
end
lib/steel_vellum/races/mountain_dwarf.fr

Of course, for the character.size = :medium instruction to work, we need to give accessors to the @size instance variable of Character:

module SteelVellum
  class Character
    attr_accessor :size
    
    # TODO: is this method really useful? It won't be used once the character creation is done
    def ability_score_increases
    end
    
    def speed
    end
    
    def darkvision
    end
    
    def languages
    end
    
    def has_advantage_on_saving_throws_against?(type)
    end
    
    def has_resistance_against?(type)
    end
    
    def proficient_with?(proficiency)
    end
    
    def special_traits
    end
  end
end
lib/steel_vellum/character.rb

Now our test passes. We can close this small loop and go back, once again, to the big one by running (yet again) the integration test. It now fails because of the next character trait that a race is supposed to give a default value to:

𝄢 ruby -Ilib test/creating_bruenor_test.rb

# Running tests with run options --seed 15813:

F

Finished tests in 0.000413s, 2421.3068 tests/s, 4842.6136 assertions/s.


Failure:
SteelVellum::CreatingBruenorTest#test_1_choose_a_race [test/creating_bruenor_test.rb:20]
Minitest::Assertion: Expected: 25
  Actual: nil

1 tests, 2 assertions, 1 failures, 0 errors, 0 skips

This time, it is Character#speed that doesn’t return the expected value. We’ll proceed as exactly like we have with #size – adding a unit test, watching it fail, making it pass, and then moving back to the integration test. And after that, we’ll have Character.darkvision to fix. In the end, this is what our MountainDwarf class and its tests will be:

require_relative "../race"

module SteelVellum
  module Races
    MountainDwarf = Race.new do
      def self.extended(character)
        character.size       = :medium
        character.speed      = 25
        character.darkvision = 60
      end
    end
  end
end
lib/steel_vellum/races/mountain_dwarf.fr
require_relative "../test_helper"
require "./lib/steel_vellum/character"
require "./lib/steel_vellum/races/mountain_dwarf"

module SteelVellum
  class Races::MountainDwarfTest < Minitest::Test
    def test_a_mountain_dwarf_character_has_a_medium_size
      character = Character.new
      
      assert_nil character.size
      character.extend Races::MountainDwarf
      
      assert_equal :medium, character.size
    end
    
    def test_a_mountain_dwarf_character_has_a_speed_of_25
      character = Character.new
      
      assert_nil character.speed
      character.extend Races::MountainDwarf
      
      assert_equal 25, character.speed
    end
    
    def test_a_mountain_dwarf_character_has_darkvision_up_to_60_feet
      character = Character.new
      
      assert_nil character.darkvision
      character.extend Races::MountainDwarf
      
      assert_equal 60, character.darkvision
    end
  end
end
test/races/mountain_dwarf_test.rb

Stepping away from BDD

Normally, keeping with our back-and-forths between the integration tests and the unit tests, our next step should probably be have to do with ability_score_increases. However, once again, I’d like to take a step back and consider our recent work.

We’ve implemented the behavior of the Races::MountainDwarf instanciated modules, because this is what our tests have covered. But we know that other races will eventually be covered by the library, and we know that they, too, will assign a size, a speed and a darkvision range to the characters. So, even though we don’t have any test to lead us there yet, we can safely assume that making this piece of business logic a bit more generic is valuable.

In practice, this means that any subclass of Race should be able to assign values to a Character’s @size, @speed and @darkvision instance variables, and the assigned values would depend on the subclass itself. This is rather easy to write tests for.

First, we need to be able to define the values that a race will assign:

require_relative "test_helper"
require "./lib/steel_vellum/race"
require "./lib/steel_vellum/character"

module SteelVellum
  class RaceTest < Minitest::Test
    def test_race_initialization
      race = Race.new(speed: 30, size: :small, darkvision: 5)
      
      assert_equal 30, race.speed
      assert_equal :small, race.size
      assert_equal 5, race.darkvision
    end
  end
end
test/race_test.rb

Then, we need to ensure that using the race to extend a Character assigns these values. We can simply cannibalize the tests for MountainDwarf; but for the sake of conciseness, we’ll squash the 3 tests into a single one with multiple assertions:

require_relative "test_helper"
require "./lib/steel_vellum/race"
require "./lib/steel_vellum/character"

module SteelVellum
  class RaceTest < Minitest::Test
    def test_race_initialization
      race = Race.new(speed: 30, size: :small, darkvision: 5)
      
      assert_equal 30, race.speed
      assert_equal :small, race.size
      assert_equal 5, race.darkvision
    end
    
    def test_extending_a_character_sets_default_for_their_traits
      race      = Race.new(speed: 25, size: :medium, darkvision: 60)
      character = Character.new
      
      assert_nil character.speed
      assert_nil character.size
      assert_nil character.darkvision
      
      character.extend race
      
      assert_equal 25,      character.speed
      assert_equal :medium, character.size
      assert_equal 60,      character.darkvision
    end
  end
end
test/race_test.rb

The implementation is pretty straightforward too, except for one subtlety:

module SteelVellum
  # TODO: maybe add a DSL for defining races (e.g. +Race.new { size :medium }+)
  class Race < Module
    attr_accessor :speed, :size, :darkvision
    
    def initialize(speed: 30, size: :medium, darkvision: 0)
      @speed, @size, @darkvision = speed, size, darkvision
    end
    
    def extended(character)
      character.size       = @size
      character.speed      = @speed
      character.darkvision = @darkvision
    end
  end
end
lbi/steel_vellum/race.rb

The #extended hook method must be defined in the class (singleton or not) of the object on which it will be called. This is why, when its definition was in the MountainDwarf class, it was sent to self. (In other words: .extended was defined as a class method of MountainDwarf). However, since we’re moving this definition up to the class of all races modules, the #extended must now be defined as an instance method5 of Race.

(Note also that we’ve also added default values in the initializer, even though we didn’t write tests for that, and therefore have no idea if this is legitimate design or not – we’re freewheeling! 🤘)

Hidden edge cases

Here is a secret about BDD: since it’s about letting the expected behavior drive the design, edge cases – in other words: unexpected behavior – can slip through. Which is why it is important to consider these edge cases when working at the unit test level, where they are easier to think about.

In our case, even though we’ve kept saying that a character’s race gives it default values for some traits, we haven’t tested for the (unlikely) situation where some would have already been defined before the race was assigned. So let’s add that. And while we’re at it, let’s cover another edge case: using a race module to extend an object which is not an instance of the Character class.

def test_extending_a_character_doesnt_change_existing_traits
  race      = Race.new(speed: 25)
  character = Character.new
  
  character.speed = 30
  character.extend race
  
  assert_equal 30, character.speed # hasn't changed to 25
end

def test_extending_an_irrelevant_class_does_nothing
  race = Race.new
  obj  = Object.new

  obj.extend race # should not raise nor do anything
end
test/race_test.rb (excerpt)

The final implementation is quite easy:

module SteelVellum
  # TODO: maybe add a DSL for defining races (e.g. +Race.new { size :medium }+)
  class Race < Module
    attr_accessor :speed, :size, :darkvision
    
    def initialize(speed: 30, size: :medium, darkvision: 0)
      @speed, @size, @darkvision = speed, size, darkvision
    end
    
    def extended(o)
      assign_traits(o) if o.kind_of? Character
    end
    
    private
    
    def assign_traits(character)
      character.size       ||= @size
      character.speed      ||= @speed
      character.darkvision ||= @darkvision
    end
  end
end
lib/steel_vellum/race.rb

Final cleanup

Now that the logic for assigning default values to a character’s racial traits is moved up to the Race class from which MountainDwarf inherits, we can clean up our previous work, by deleting the now redundant unit tests in MountainDwarfTest, and the logic from MountainDwarf:

require_relative "../race"

module SteelVellum
  module Races
    MountainDwarf = Race.new(size: :medium, speed: 25, darkvision: 60)
  end
end
lib/steel_vellum/races/mountain_dwarf.rb

And this is it (for now)! We’ve successfully implemented the first actual piece of logic in our library, which is actually quite a lot:

  • We can define a character race, or at least 3 of its traits for now.
  • These traits are automatically assigned to a character when their race is chosen during character creation.
  • For developers who’ll eventually use our library, assigning a Race to a Character object gives it some kind of “type”, which is probably a false good idea, but fun nonetheless.

We can now return to our big loop, once again, and see what the DM of BDDing has for us in the next installment of this series!


  1. Roughly speaking. 

  2. Feel free to try this out in an IRB console: create 2 instances of Character, create a new Race, include it in Character with Character.include the_new_race and see that both the instances now “are” also of this race. 

  3. Don’t be mistaken, this class has nothing to do with the singleton design pattern, or the Singleton module! 

  4. Technically, even if store in an instance variable, the value will be returned by a method (namely, a reader accessor), but hopefully you see what I mean. 

  5. As an exercice, can you guess what would happen, and why, if within the Race class we’d write def self.extended