Calculating Leap Years in PHP with PHPUnit

I don’t know about you, but using my child-like-memory-of-a-brain, always thought that a leap year was one which was wholly divisible by 4. So, the year 2000….the year 1992….the year 2020…all leap years, right? Well, actually, no.

The rule is really this:

  • a leap year is one which is divisible by 4 and not 100 OR,
  • if it is divisble by 4 and 100, then it must also be divisible by 400.

So, in effect, the year 1992 is a leap year (divisble by 4, but not 100) but 1900 isn’t (divisble by 4 and 100, but not 400).

Who’d have guessed and what’s more, that I would be re-educated from a Microsoft Excel article!

So what’s making me talk about this? Well, I was intrigued by a piece of code in the excellent Advanced PHP Programming book by George Schlossnagle which jarred my mistaken belief about the calculation. Here is the pertinent portion which is used to calculate the number of days in February if it is and isn’t a leap year:

if((($year % 4 == 0) && ($year % 100)) || ($year % 400 == 0)) {
return 29;
}
else {
return 28;
}

Therefore, in a leap year, February sports 29 days or otherwise, the meagre 28.

Now that I have my head around the logic, the code makes sense, but what if that wasn’t the case or you hadn’t read my english explanation of how the calculation is done? One option, which we should do anyway, is to use PHPUnit. Let’s do that in this case.

I need to make some assumptions, so let’s start from the idea that you have PHP & composer installed, at least, and like me, you might be using Windows.

Create a directory named leap

F:\> mkdir leap
F:\> cd leap

Now install PHPUnit

F:\leap>composer require phpunit/phpunit
Using version ^9.0 for phpunit/phpunit
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 30 installs, 0 updates, 0 removals
- Installing sebastian/version (3.0.0): Downloading (100%)
- Installing sebastian/type (2.0.0): Downloading (100%)
- Installing sebastian/resource-operations (3.0.0): Downloading (100%)
- Installing sebastian/recursion-context (4.0.0): Downloading (100%)
- Installing sebastian/object-reflector (2.0.0): Downloading (100%)
- Installing sebastian/object-enumerator (4.0.0): Downloading (100%)
- Installing sebastian/global-state (4.0.0): Downloading (100%)
- Installing sebastian/exporter (4.0.0): Downloading (100%)
- Installing sebastian/environment (5.0.2): Downloading (100%)
- Installing sebastian/diff (4.0.0): Downloading (100%)
- Installing sebastian/comparator (4.0.0): Downloading (100%)
- Installing phpunit/php-timer (3.0.0): Downloading (100%)
- Installing phpunit/php-text-template (2.0.0): Downloading (100%)
- Installing phpunit/php-invoker (3.0.0): Downloading (100%)
- Installing phpunit/php-file-iterator (3.0.0): Downloading (100%)
- Installing theseer/tokenizer (1.1.3): Loading from cache
- Installing sebastian/code-unit-reverse-lookup (2.0.0): Downloading (100%)
- Installing phpunit/php-token-stream (4.0.0): Downloading (100%)
- Installing phpunit/php-code-coverage (8.0.1): Downloading (100%)
- Installing doctrine/instantiator (1.3.0): Loading from cache
- Installing phpdocumentor/reflection-common (2.0.0): Loading from cache
- Installing symfony/polyfill-ctype (v1.15.0): Loading from cache
- Installing webmozart/assert (1.7.0): Loading from cache
- Installing phpdocumentor/type-resolver (1.1.0): Loading from cache
- Installing phpdocumentor/reflection-docblock (5.1.0): Loading from cache
- Installing phpspec/prophecy (v1.10.3): Loading from cache
- Installing phar-io/version (2.0.1): Downloading (100%)
- Installing phar-io/manifest (1.0.3): Downloading (100%)
- Installing myclabs/deep-copy (1.9.5): Loading from cache
- Installing phpunit/phpunit (9.0.2): Downloading (100%)
sebastian/global-state suggests installing ext-uopz (*)
sebastian/environment suggests installing ext-posix (*)
phpunit/php-invoker suggests installing ext-pcntl (*)
phpunit/php-code-coverage suggests installing ext-pcov (*)
phpunit/php-code-coverage suggests installing ext-xdebug (*)
phpunit/phpunit suggests installing ext-soap (*)
phpunit/phpunit suggests installing ext-xdebug (*)
Writing lock file
Generating autoload files

Testing PHPUnit is Installed

You can check it is installed by running it, so do that now.

F:\leap>vendor\bin\phpunit
PHPUnit 9.0.2 by Sebastian Bergmann and contributors.

Usage:
phpunit [options] UnitTest.php
phpunit [options] <directory>

Code Coverage Options:
--coverage-clover <file> Generate code coverage report in Clover XML format
--coverage-crap4j <file> Generate code coverage report in Crap4J XML format
--coverage-html <dir> Generate code coverage report in HTML format

[bits chopped for brevity]

OK, looking good. Next step is to have a basic PHPUnit configuration file. We won’t try and be fancy with all the options, here; just enough to make it work, TDD style ;-)

A Basic PHPUnit Configuration File

Open up your editor and save the following into a file named: phpunit.xml in the leap directory.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="LeapYearTests">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

The basic idea here is that we point to where our vendor/autoload file is so that PHPUnit can find any associated libraries, and tell PHPUnit where to find our tests. In this case, that’s the folder tests.

Creating a Simple Test

First let’s create that tests folder we just mentioned.

F:\leap> mkdir tests

And using your text editor again, create the following file named LeapTest.php inside the tests folder.

<?php

use PHPUnit\Framework\TestCase;

class LeapTest extends TestCase
{
public function testCase()
{
$this->assertTrue(true);
}
}

This is just about the simplest test we could have. We’re going to test that true is, err….true. If you’re interested, there are a couple of features worth noticing.

  • Class name
    • Has the suffix Test. PHPUnit will specifically look for these files and treat them as test classes.
  • testCase() function
    • Again, PHPUnit is hunting for any functions preceded by the word test. You can also use a docblock if you like, but for now, we’ll stick with this style.

Before we move on, let’s make sure it is working by running phpunit. Ensure you are in the leap folder, and then run this:

F:\leap>vendor\bin\phpunit
PHPUnit 9.0.2 by Sebastian Bergmann and contributors.

. 1 / 1 (100%)

Time: 68 ms, Memory: 4.00 MB

OK (1 test, 1 assertion)

If the Gods were smiling upon you, you should have the above output: all is OK since 1 test resulted in 1 assertion. If not, go back and ensure you didn’t introduce any typos in the test, or the configuration XML file, before trying again.

Testing the Code

I know we’ve taken a while to get to the purpose of this article, but I thought it was worth going through how to set up a basic PHPUnit harness from nothing for those that might find it useful. It’s time to test our function now.

To start, let’s remove that function and add our own function into the test case. Not the right way to do it in a proper project, but this is just to illustrate the point, so I get a free pass!

<?php

use PHPUnit\Framework\TestCase;

class LeapTest extends TestCase
{
public function daysInFebruary(int $year) : int
{
if((($year % 4 == 0) && ($year % 100)) || ($year % 400 == 0)) {
return 29;
}
else {
return 28;
}
}
}

That matches what George had in his book in terms of logic. Now, borrowing from the Microsoft article, I am going to use all of their examples and answers, together with a new assert - assertEquals - whose usage will become obvious when you see it. Here’s the whole class:

<?php

use PHPUnit\Framework\TestCase;

class LeapTest extends TestCase
{
public function test_year_is_a_leap_year()
{
# are not leap years
# 1700, 1800, 1900, 2100, 2200, 2300, 2500, 2600
$this->assertEquals($this->daysInFebruary(1700), 28);
$this->assertEquals($this->daysInFebruary(1800), 28);
$this->assertEquals($this->daysInFebruary(1900), 28);
$this->assertEquals($this->daysInFebruary(2100), 28);
$this->assertEquals($this->daysInFebruary(2200), 28);
$this->assertEquals($this->daysInFebruary(2300), 28);
$this->assertEquals($this->daysInFebruary(2500), 28);
$this->assertEquals($this->daysInFebruary(2600), 28);

# are leap years
# 1988, 1992, 1996, 1600, 2000, 2400
$this->assertEquals($this->daysInFebruary(1988), 29);
$this->assertEquals($this->daysInFebruary(1992), 29);
$this->assertEquals($this->daysInFebruary(1996), 29);
$this->assertEquals($this->daysInFebruary(1600), 29);
$this->assertEquals($this->daysInFebruary(2000), 29);
$this->assertEquals($this->daysInFebruary(2400), 29);
}

public function daysInFebruary(int $year) : int
{
if((($year % 4 == 0) && ($year % 100)) || ($year % 400 == 0)) {
return 29;
}
else {
return 28;
}
}
}

Can you guess what comes next? Yep: run phpunit.

F:\leap>vendor\bin\phpunit
PHPUnit 9.0.2 by Sebastian Bergmann and contributors.

. 1 / 1 (100%)

Time: 67 ms, Memory: 4.00 MB

OK (1 test, 14 assertions)

Woohoo! 1 test and 14 assertions, all resulting in that coveted OK, so we can be sure George’s code works as expected.

Alright, we could end here, but all that repeated code is making my eyes water. Let’s finish off by using a feature called Data Providers which will allow us to parameterize these tests.

The first thing we need is a function which will return our inputs and outputs, so add this under our test function.

public function leapYearProvider()
{
return [
[1700, 28],
[1800, 28],
[1900, 28],
[2100, 28],
[2200, 28],
[2300, 28],
[2500, 28],
[2600, 28],
[1988, 29],
[1992, 29],
[1996, 29],
[1600, 29],
[2000, 29],
[2400, 29],
];
}

Strictly speaking, it didn’t have to be called somethingProvider but that’s a good standard to use and what others will expect. Lastly, we need to add an annotation to our test to tell PHPUnit which provider to use. Add this docblock in front of the test function.

/**
* @dataProvider leapYearProvider
*/

14 more tests for good luck:

F:\leap>vendor\bin\phpunit
PHPUnit 9.0.2 by Sebastian Bergmann and contributors.

.............. 14 / 14 (100%)

Time: 67 ms, Memory: 4.00 MB

OK (14 tests, 14 assertions)

One final note. Did you see how the output changed from running 1 test, with 14 assertions, to 14 tests, with 14 assertions? That’s the data provider at work.

Time to end here; I’m all tested out though much wiser about leap years.


Hi! Did you find this useful or interesting? I have an email list coming soon, but in the meantime, if you ready anything you fancy chatting about, I would love to hear from you. You can contact me here or at stephen ‘at’ logicalmoon.com