PHP Kata: Roman Numerals


This article is a step by step guide on how to do Roman Numerals Kata in PHP.

Why kata?

The best answer for this question is a quote from Robert C. Martin known as Uncle Bob:

Indeed, any kind of professional craftsman or artisan must practice their adopted trade in order to execute it well when it counts.

In a spare time read this wonderful article: What's all this Nonsense about Katas?.

Goal of Roman Numerals Kata

The goal of this kata is very simple. You should write a function to convert from normal numbers to Roman Numerals. That's it!

But Kata is about making you a better software developer. This is why I would like you to:

  • do it using TDD approach,
  • write units in Kahlan,
  • make sure that your code is PSR-1 and PSR-12 compliant.

I owe you a short introduction to TDD. This will be the shortest introduction ever - wait for it: "Write the test first!".

Before we start it might be useful to check out what those Roman Numerals are. Like always wiki page to the rescue!

Project setup

Before we start you need to set up a base PHP project. You can follow my other tutorial how to start with PHP project. Or if you using git, you can create a repository from my PHP 8 project template on GitHub.

How to do it? Go to: php8-base-project. Click on a big green button "Use this template". Provide repository name - I will use "php-kata-roman-numerals". Add a description and select if this will be a public or private repository. Verify if "Include all branches" is unchecked. Click on "Create repository from template" - wait a few seconds and you are ready to clone it!

# create project directory
mkdir -p ~/webee-school/php-kata/roman-numerals && cd $_

# clone your brand new repository (remember to use your repository address)
git clone git@github.com:adam-webee/php-kata-roman-numerals.git .

Adjust composer.json to fit your needs. The Minimum will be to change the project name. But consider also other changes. I changed only these two things:

{
    "name": "webee-online/php-kata-roman-numerals",
    "description": "Roman Numerals Kata in PHP for the article on WeBee.School"
}

I also removed samples - as we do not need them:

rm -rf src/* spec/* test/*

Now commit changes into the repository:

git add .
git commit -m "Repository cleanup;"
git push

Finally, install dependencies:

composer install

TDD approach - our first test

We will start with something very simple. Assume that our front class will be RomanNumerals. With this assumption in mind, we can introduce the first test - that must fail. Create test specification file: spec/RomanNumerals.spec.php

<?php

declare(strict_types=1);

namespace WeBee\Tests\Spec;

describe(
    'Roman Numerals',
    function () {
        it(
            'can be instantiated',
            function () {
                expect(new RomanNumerals())->toBeAn('object');
            }
        );
    }
);

Great! We have our first test ready. Check if it fails:

./vendor/bin/kahlan

Yippee ki-yay, coder! Big tasty error is what we expected. Now make this test pass.

Create PHP file: src/RomanNumerals.php

<?php

declare(strict_types=1);

namespace WeBee;

final class RomanNumerals
{
}

Our test was failing because of missing RomanNumerals class. Now we have it so add an appropriate use statement:

<?php

declare(strict_types=1);

namespace WeBee\Tests\Spec;

use WeBee\RomanNumerals;

describe(
    'Roman Numerals',
    function () {
        it(
            'can be instantiated',
            function () {
                expect(new RomanNumerals())->toBeAn('object');
            }
        );
    }
);

Fire up tests again, and see this wonderful "Passed 1 of 1"! We finished our first TDD "fail -> pass" cycle. But I do not feel that we tested our code enough. I want to be sure that returned object is of type RomanNumerals. Add additional expectation to our test:

<?php

declare(strict_types=1);

namespace WeBee\Tests\Spec;

use WeBee\RomanNumerals;

describe(
    'Roman Numerals',
    function () {
        it(
            'can be instantiated',
            function () {
                $rn = new RomanNumerals();
                expect($rn)->toBeAn('object');
                expect($rn)->toBeAnInstanceOf('WeBee\RomanNumerals');
            }
        );
    }
);

Fire up tests again, and this time our test is green without a touch to the code! Me like it ;)

Start with a serious PHP development

Starting from here I will assume that you will trigger tests before and after each code change. Also, I will include, in code snippets, only relevant code. I think you will find out the correct place to put it in your code. If no you can always check out the repository I cloned at the beginning.

According to our goal, we need to create code that will convert numbers into Roman Numerals. I like to start simple. Our class should be able to convert 1 -> I. Write a test for it:

it(
    'can convert 1 into I',
    function () {
        $rn = new RomanNumerals();
        expect($rn->convert(1))->toBe('I');
    }
);

And the simplest code that will make this test passing:

public function convert(int $number): string
{
    return 'I';
}

Look - we have solution that is working correctly for a single use case! We are on fire! Add another test:

it(
    'can convert 2 into II',
    function () {
        $rn = new RomanNumerals();
        expect($rn->convert(2))->toBe('II');
    }
);

And again the simplest solution to make the test pass:

public function convert(int $number): string
{
    return $number === 1 ? 'I' : 'II';
}

Let's face the true - it looks like shit. As we have all test passing we can refactor our code with confidence. First of all, in our tests we have code duplication - get ride of it:

describe(
    'Roman Numerals',
    function () {
        given(
            'rn',
            function () {
                return new RomanNumerals();
            }
        );

        it(
            'can be instantiated',
            function () {
                expect($this->rn)->toBeAn('object');
                expect($this->rn)->toBeAnInstanceOf('WeBee\RomanNumerals');
            }
        );

        it(
            'can convert 1 into I',
            function () {
                expect($this->rn->convert(1))->toBe('I');
            }
        );

        it(
            'can convert 2 into II',
            function () {
                expect($this->rn->convert(2))->toBe('II');
            }
        );
    }
);

A little bit better - but still there is a place for improvement:

describe(
    'Roman Numerals',
    function () {
        given(
            'rn',
            function () {
                return new RomanNumerals();
            }
        );

        it(
            'can be instantiated',
            function () {
                expect($this->rn)->toBeAn('object');
                expect($this->rn)->toBeAnInstanceOf('WeBee\RomanNumerals');
            }
        );

        given(
            'dataSet',
            function () {
                return [
                    1 => 'I', 2 => 'II',
                ];
            }
        );

        it(
            'can convert numbers into roman numerals',
            function () {
                foreach ($this->dataSet as $number => $romanNumeral) {
                    expect($this->rn->convert($number))->toBe($romanNumeral);
                }
            }
        );
    }
);

Ok - our tests are now easier to go with. Now it is time to do something with our class:

private $convertMap = [
    1 => 'I', 2 => 'II',
];

public function convert(int $number): string
{
    return $this->convertMap[$number];
}

Not perfect but way better than if we used before.

Why TDD is so powerful

I'm wondering right now if you figured out why TDD is so great? Let's take a moment and analyze what just happened. In the beginning, we did not have any code, but we developed the test that described how this code should work. Then we develop a little piece of code that we were sure to abut it was working correctly. Why?

Because we had a test that proves it! We repeated this cycle two times. In each cycle, we started with assumptions about what is correct behavior. Having this written we were able to deliver the easiest solution possible.

This is a true power of TDD - you must find out how something must work before you code it!

But it's not all. Did you notice that we were able to refactor tests with high confidence? Why? Because we had something that was able to test our tests. You are right - this was our code. We were certain of its correctness because we tested it before playing with tests.

Then we were able to play again with the code - thanks to our tests that proved to work correctly with the previous code. All of this was possible for a single reason. Thanks to the tests we always have two sources of true - tests and code. We can always change one being sure that the second one will prove the correctness of the change.

I hope you will find TDD as exciting as I do.

Equivalence classes

In the discipline of testing, there is something called "equivalence classes". In very short words an equivalence class describes the data set for which program will follow the same execution path. Why this is important? Check out this function:

public function getColor(int $number): string
{
    return $number >= 0 ? 'black' : 'red';
}

It has two equivalence classes:

  • numbers below 0 -> execution path goes thru 'red';
  • numbers above 0 and 0 -> execution path goes thru 'black';

And how this is related to tests? It helps us to test only cases that are worth testing. In this particular example it is worth testing:

  • any number lower than 0 - e.g. -1
  • any number higher than or equal 0 - e.g. 1 If we develop tests for these cases we will cover all possible execution paths for getColor function.

Having this explained we can go back to our Kata.

Edge cases

Instead of adding consecutive numbers to our test data set, we will try to test the edge case. With standard notation, the highest number you can write is 3999 -> MMMMCMXCIX. Develop a test for it:

it(
    'can throw an error if input is higher then 3999',
    function () {
        $e = function () {
            $this->rn->convert(4000);
        };

        expect($e)->toThrow(new DomainException('3999 is the highest number possible to convert'));
    }
);

And now code that makes this test pass:

public function convert(int $number): string
{
    if (3999 < $number) {
        throw new DomainException('3999 is the highest number possible to convert');
    }

    return $this->convertMap[$number];
}

Similarities and repetitions

Generating Roman Numerals is quite easy. We have 9 elements that represent units. Another 9 elements to represent tens and 9 elements to represent hundreds. And only 3 elements to represent thousands. We can write it as PHP array:

[
    # 9 elements for units:
    ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
    # 9 elements fo tens:
    ['X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
    # 9 elements for hundreds:
    ['C', 'CC', 'CCC', 'CD', 'D', 'DC', 'DCC', 'DCCC', 'CM'],
    # 3 elements for thousands:
    ['M', 'MM', 'MMM'],
]

So lets analyse number 3999 - we can write it as 3000 + 900 + 90 + 9. If we remove 0 we will have 3 + 9 + 9 + 9. To convert it to a roman numeral we will:

  • take the third element from thousands: MMM and set it as a result: MMM
  • take the ninth element from hundreds: CM and append it to a result: MMMCM
  • take the ninth element from tens: XC and append it to a result: MMMCMXC
  • take the ninth element from units: IX and append it to a result: MMMCMXCIX Check out the result from the last step it is 3999 in roman numerals.

I think you should now see how we will implement our convert method. So which case we should test now? I will start from 9 -> IX. Test:

given(
    'dataSet',
    function () {
        return [
            1 => 'I', 2 => 'II', 9 => 'IX',
        ];
    }
);

Code:

private $convertMap = [
    ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
];

public function convert(int $number): string
{
    if (3999 < $number) {
        throw new DomainException('3999 is the highest number possible to convert');
    }

    return $this->convertMap[0][$number - 1];
}

Together with making this test pass - we made our program work correctly for numbers from 1 to 9. In other words - we covered the first equivalence class: numbers from 1 to 9.

Play with tens

Now we will work with 10 -> X as it is first citizen of second equivalence class and edge case of it. Test:

given(
    'dataSet',
    function () {
        return [
            1 => 'I', 2 => 'II', 9 => 'IX',
            10 => 'X',
        ];
    }
);

Code:

private $convertMap = [
    ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
    ['', 'X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
];

public function convert(int $number): string
{
    if (3999 < $number) {
        throw new DomainException('3999 is the highest number possible to convert');
    }

    $formattedNumber = sprintf('%04d', $number);

    return $this->convertMap[1][$formattedNumber[2]] . $this->convertMap[0][$formattedNumber[3]];
}

Second equivalence class is now covered. But to be sure we will add test for its last citizen edge case 99 => XCIX.

given(
    'dataSet',
    function () {
        return [
            1 => 'I', 2 => 'II', 9 => 'IX',
            10 => 'X', 99 => 'XCIX',
        ];
    }
);

Time to refactor

Our code starts to smell again. As we have it well tested we can refactor it with no worry:

private $convertMap = [
    ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
    ['', 'X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
    [''],
    [''],
];

public function convert(int $number): string
{
    if (3999 < $number) {
        throw new DomainException('3999 is the highest number possible to convert');
    }

    $digits = str_split(sprintf('%04d', $number));
    $magnitude = 3;
    $result = '';

    foreach ($digits as $digit) {
        $result .= $this->convertMap[$magnitude--][$digit];
    }

    return $result;
}

Now code is re-factorized. Time to add new tests. We will add 2 tests for 3rd equivalence class at once:

given(
    'dataSet',
    function () {
        return [
            1 => 'I', 2 => 'II', 9 => 'IX',
            10 => 'X', 99 => 'XCIX',
            100 => 'C', 999 => 'CMXCIX',
        ];
    }
);

And quick code change:

private $convertMap = [
    ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
    ['', 'X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
    ['', 'C', 'CC', 'CCC', 'CD', 'D', 'DC', 'DCC', 'DCCC', 'CM'],
    [''],
];

And now it is time for 4th equivalence class:

given(
    'dataSet',
    function () {
        return [
            1 => 'I', 2 => 'II', 9 => 'IX',
            10 => 'X', 99 => 'XCIX',
            100 => 'C', 999 => 'CMXCIX',
            1000 => 'M', 3999 => 'MMMCMXCIX',
        ];
    }
);
private $convertMap = [
    ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
    ['', 'X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
    ['', 'C', 'CC', 'CCC', 'CD', 'D', 'DC', 'DCC', 'DCCC', 'CM'],
    ['', 'M', 'MM', 'MMM'],
];

Final touch

Our code is almost ready. We have to cover one more use case. What about 0 and negative numbers? Yes - we will add another test. But before that, we need to refactor our code again. Why? Our exception message is highly related to the maximal value. We will make it more general. At first, change the test:

it(
    'can throw an error if input is higher then 3999',
    function () {
        $e = function () {
            $this->rn->convert(4000);
        };

        expect($e)->toThrow(new DomainException('Number must be in the range from 1 to 3999'));
    }
);

Now change the code:

if (3999 < $number) {
    throw new DomainException('Number must be in the range from 1 to 3999');
}

And we are ready for another test:

it(
    'can throw an error if input is 0',
    function () {
        $e = function () {
            $this->rn->convert(0);
        };

        expect($e)->toThrow(new DomainException('Number must be in the range from 1 to 3999'));
    }
);

And small change in the code:

if (1 > $number || 3999 < $number) {
    throw new DomainException('Number must be in the range from 1 to 3999');
}

Fight for 100% certainty

We have covered all our code quite well. But to be sure that it works correctly we should add few random cases also:

given(
    'dataSet',
    function () {
        return [
            1 => 'I', 2 => 'II', 9 => 'IX',
            10 => 'X', 99 => 'XCIX', 54 => 'LIV',
            100 => 'C', 999 => 'CMXCIX', 678 => 'DCLXXVIII',
            1000 => 'M', 3999 => 'MMMCMXCIX', 2163 => 'MMCLXIII',
        ];
    }
);

I'm glad to see you here. I hope you find this article helpful.

Test out!

Not enough?


Would you like me to build and conduct training tailored to your needs? I'm waiting for your call or message.

address icon

WeBee.Online Adam Wojciechowski
ul. Władysława Łokietka 5/2
70-256 Szczecin, Poland