Develop a Blog - Part 2: TDD first Domain Models

In the previous article we made an initial design for a blog based on a list of requirements. Today we will start writing some PHP code. We will use the class diagram from previous article as a guide, but we will use Test Driven Development (TDD) to hash out the details of our design. The TDD approach will help us to make sure we keep our code easy to read, working and testable. This might lead to some different design decisions than we made in the previous article, but that is the beauty of programming. The code and our designs will keep evolving while we are writing our application.

The code in this article is available on Github:

This article is part of a series:

Before we start coding, let's first have a look at our requirements and UML diagram again.

Requirements and design

These were the requirements we stated for a simple blog:

As a reader I want to

  1. View the latest published blog posts
  2. View the latest published blog posts written by an author
  3. View the latest published blog posts in a category
  4. Read an individual published blog post

As an blog author I want to

  1. (Co-)Write a new blog post
  2. Write the content of a blog post in Markdown or HTML
  3. Update an existing blog post
  4. Remove an existing blog post
  5. Publish a blog post in a category
  6. Schedule a blog post to be published at a later date
  7. View an individual scheduled or draft blog post

To satisfy these requirements we made the following initial design:Akaq2FEshlmVTtztY61WkFLyyxWUAQ3aYSuhlGEA.png

In the previous article we decided to ignore the infrastructure (database, the web, etc) for now. We want to focus on the key concepts of our blog. The infrastructure will only distract us from that. For now we will continue focussing on the domain objects and ignore the database, filesystem and the fact that we're probably making a web application.

The first test

The main concept of our Blog is the BlogPost. This is also the class I would like to focus on in this article. But before we develop the BlogPost class, we should first think about how we would like to interact with it. The easiest way to do that is to start with a test. The test will be the first user of our BlogPost.

What is the simplest test we can come up with? The creation of a new BlogPost. We always want to create a BlogPost in a valid state. Which means we will need an Author, a Category, a title, probably an introduction (something we can show if we only show part of the blog post) and the content right from the start. We can enforce that by requiring these attributes in the constructor:

/** @test */
public function can_create_a_new_blogpost()
{
    $blogPost = new BlogPost(
        author: new Author('Mark'),
        category: new Category('PHP'),
        title: 'My first blog post',
        introduction: 'A short introduction',
        content: 'The full article',
    );

    $this->assertEquals(new Author('Mark'), $blogPost->getAuthor());
    $this->assertEquals(new Category('PHP'), $blogPost->getCategory());
    $this->assertEquals('My first blog post', $blogPost->getTitle());
    $this->assertEquals('A short introduction', $blogPost->getIntroduction());
    $this->assertEquals('The full article', $blogPost->getContent());
}

There is a problem with this test. To make the test work we have to think about the Author and Category as well. While creating this test we decided that both the Author and the Category have a name which we provide through the constructor. These are decisions that aren't related to the Blogpost and we shouldn't make these decisions here. We have to come up with a simpler test.

Test drive the Author

We can start with a simpler concept like the Auhor. A Blogpost has at least one Author, but if we look at our UML diagram we see that the Author doesn't have any dependencies on its own. This makes the Author an ideal candidate to start our journey. Let's add an @ignore to the create_a_blogpost test and come up with a new test for the Author.

What is the most simple thing we can say about an Author that is relevant to our blog? Well, in the can_create_a_new_blogpost test we wanted to create one, so let's start with a that. For now we don't know much about an author, except that it has a name.

/** @test */
public function can_create_author()
{
    $author = new Author(
        name: 'Mark',
    );

    $this->assertEquals('Mark', $author->getName());
}

This is much better. We are only making decision about the Author, which allows us to focus on one thing at a time. Let's run the test.

Error : Class "Tests/Author" not found

Ok, that makes sense. We have to create the Author class.

<?php


namespace Webdevils\Blog;


class Author
{

}

Run the test again.

Error : Unknown named parameter $name

Let's add a constructor to the Author class:

public function __construct(string $name)
{
}

Again, run the test:

Error : Call to undefined method Webdevils\Blog\Author::getName()

The error message changed again, we are making progress. We need to add a getName() method to our Author class.

public function getName() : string
{
    return '';
}

And again, run the test:

Failed asserting that two strings are equal.
Expected :'Mark'
Actual   :''

Good news, we finally got the error we expected: the name of the Author should be stored when a new Author is created. We can easily resolve this error, by setting a private $name variable in the Author class and return it in the getName() method.

class Author
{
    private string $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function getName() : string{
        return $this->name;
    }
}

Run the test and we are green. We can created an Author!

Anything to refactor? For now, I don't think so. The code looks nice and clean.

What's next? Let's run through the edge cases. What happens if we give the Author no name? First, I would get a PHP error that the $name parameter wasn't set. That is good, but what if I would provide an empty string? Currently that would work. I don't think that would make sense for a blog though. You need at least a single character to display as the Author name next to a BlogPost.

We need to prevent this case from happening. We always want a valid Author when we create one. So let's write a test. What needs to happen if I provide an empty string as the name? It is an exceptional situation which would warrant an Exception. To make it easier on our users to identify the specific error we can throw an InvalidAuthor exception.

/** @test */
public function an_author_needs_a_name()
{
    $this->expectException(InvalidAuthor::class);

    new Author(
        name: ''
    );
}

With the previous test I went through every single step I go through when writing code. I write a test, run the test, get an error, fix the error, run the test, get another error, fix the error, run the test, etc until I get a green test. For brevity I won't do that for the rest of the article. I will just provide the test code and the code to bring the test to green. However, I would recommend while writing your own code, to actually follow the cycle we followed for the first test.

public function __construct(string $name)
{
    if(strlen($name) === 0) {
        throw new InvalidAuthor('Name is required');
    }

    $this->name = $name;
}

That looks good. Any chance to refactor? Maybe we could extract the validation in a separate method, but for now I think it is clear and good enough.

What other edge cases do we have? What if somebody chooses a very long name. Maybe it is a good idea to restrict that as well. Let's write another test.

/** @test */
public function the_name_cannot_be_too_long()
{
    $this->expectException(InvalidAuthor::class);

    new Author(
        name: 'A very long name with more than 30 characters'
    );
}

And some code to bring us back to green.

public function __construct(string $name)
{
    if(strlen($name) === 0 || strlen($name) > 30) {
        throw new InvalidAuthor('Name is required and must be maximum 30 characters');
    }

    $this->name = $name;
}

Now the intent of the code becomes a bit unclear. Let's refactor to clarify to the reader what we try to achieve. We can do that by extracting the validations in their own method:

protected function isEmpty(string $name): bool
{
    return strlen($name) === 0;
}

protected function isTooLong(string $name): bool
{
    return strlen($name) > 30;
}

public function __construct(string $name)
{
    if($this->isEmpty($name) || $this->isTooLong($name)) {
        throw new InvalidAuthor('Name is required and must be maximum 30 characters');
    }

    $this->name = $name;
}

This makes it much better readable: when the name is empty OR too long the Author is invalid. There are still a few things we could improve here:

  • Move the "magic" numbers 0 and 30 to their own constants
  • Change the check in the constructor to give a different message when the name is empty and when the name is too long

After these refactors we will end up with the following code for an Author:

<?php


namespace Webdevils\Blog;

use Webdevils\Blog\Exceptions\InvalidAuthor;

class Author
{
    const MIN_NAME_LENGTH = 1;
    const MAX_NAME_LENGTH = 30;

    private string $name;

    protected function isTooShort(string $name): bool{
        return strlen($name) < self::MIN_NAME_LENGTH;
    }

    protected function isTooLong(string $name): bool{
        return strlen($name) > self::MAX_NAME_LENGTH;
    }

    public function __construct(string $name)
    {
        if ($this->isTooShort($name)) {
            throw new InvalidAuthor('Name must be minimum '.self::MIN_NAME_LENGTH.' characters long');
        }
        if ($this->isTooLong($name)) {
            throw new InvalidAuthor('Name must be maximum '.self::MAX_NAME_LENGTH.' characters long');
        }

        $this->name = $name;
    }

    public function getName() : string{
        return $this->name;
    }
}

I think that looks pretty good for now. We don't need any more functionality in the Author before we can start working on the BlogPost, so let's continue with the Category.

Test drive the Category

The Category is the other class we need before we can start on BlogPost.

A Category should have a name. The restrictions on the name are a bit different than the name of an Author. A category name with only one character is a bit short so we will restrict it to minimum of three characters. A maximum of 30 characters seems fine for now.

Since the code for the Category will be quite similar to the code for the Author (for now), I won't go through it step by step. Below is the test code with some basic tests for creating a Category:

<?php

namespace Tests;

use PHPUnit\Framework\TestCase;
use Webdevils\Blog\Category;
use Webdevils\Blog\Exceptions\InvalidCategory;

class CategoryTest extends TestCase
{
    /** @test */public function can_create_a_category()
    {
        $category = new Category(
            name: 'PHP'
        );

        $this->assertEquals('PHP', $category->getName());
    }

    /** @test */public function a_category_name_must_be_minimum_three_characters()
    {
        $this->expectException(InvalidCategory::class);

        new Category(
            name: 'No'
        );
    }

    /** @test */public function a_category_must_be_maximum_30_characters()
    {
        $this->expectException(InvalidCategory::class);

        new Category(
            name: 'A category with a too long name'
        );
    }
}

And the code to bring these tests to green:

<?php


namespace Webdevils\Blog;

use Webdevils\Blog\Exceptions\InvalidCategory;

class Category
{
    const MIN_NAME_LENGTH = 3;
    const MAX_NAME_LENGTH = 30;

    private string $name;

    protected function isTooShort(string $name): bool{
        return strlen($name) < self::MIN_NAME_LENGTH;
    }

    protected function isTooLong(string $name): bool{
        return strlen($name) > self::MAX_NAME_LENGTH;
    }

    public function __construct(string $name)
    {
        if ($this->isTooShort($name)) {
            throw new InvalidCategory('Category name must be minimum '.self::MIN_NAME_LENGTH.' characters');
        }
        if ($this->isTooLong($name)) {
            throw new InvalidCategory('Category name must be maximum '.self::MAX_NAME_LENGTH.' characters');
        }

        $this->name = $name;
    }

    public function getName() : string{
        return $this->name;
    }
}

The code looks very similar to the Author class. Especially the validation code is repeated in both classes. We are green right now and we can refactor. First, we need to make the validation code more similar in both classes. We can do that by moving the minimum and maximum characters to a parameter like this:

protected function isTooShort(string $name, int $minChars): bool
{
    return strlen($name) < $minChars;
}

protected function isTooLong(string $name, int $maxChars): bool
{
    return strlen($name) > $maxChars;
}

Then we can extract the validation methods to a common Validation Trait:

<?php


namespace Webdevils\Blog;

trait Validation
{
    protected function isTooShort(string $name, int $minChars): bool{
        return strlen($name) < $minChars;
    }

    protected function isTooLong(string $name, int $maxChars): bool{
        return strlen($name) > $maxChars;
    }
}

And that's it! We can finally start with the BlogPost class!

Creation of a BlogPost

We already had a test for creating a blog post. Let's bring it back:

/** @test */
public function can_create_a_new_blogpost()
{
    $blogPost = new BlogPost(
        author: new Author('Mark'),
        category: new Category('PHP'),
        title: 'My first blog post',
        introduction: 'A short introduction',
        content: 'The full article',
    );

    $this->assertEquals(new Author('Mark'), $blogPost->getAuthor());
    $this->assertEquals(new Category('PHP'), $blogPost->getCategory());
    $this->assertEquals('My first blog post', $blogPost->getTitle());
    $this->assertEquals('A short introduction', $blogPost->getIntroduction());
    $this->assertEquals('The full article', $blogPost->getContent());
}

We made all necessary decisions on the Category and Author which allows us to focus on the on the creation of a BlogPost. Below the code to bring this test to green:

<?php


namespace Webdevils\Blog;

class BlogPost
{
    private Author $author;
    private Category $category;
    private string $title;
    private string $introduction;
    private string $content;

    public function __construct(
        Author $author,
        Category $category,
        string $title,
        string $introduction,
        string $content,
    ) {
        $this->author = $author;
        $this->category = $category;
        $this->title = $title;
        $this->introduction = $introduction;
        $this->content = $content;
    }

    public function getAuthor() : Author{
        return $this->author;
    }

    public function getCategory() : Category{
        return $this->category;
    }

    public function getTitle() : string{
        return $this->title;
    }

    public function getIntroduction() : string{
        return $this->introduction;
    }

    public function getContent() : string{
        return $this->content;
    }
}

The next step is to make sure that we can only create valid BlogPosts. PHP type-hinting is already helping us a bit. We need to provide an Author and Category object to prevent a fatal error. These objects already take care of their own validation. Which leaves the title, introduction, and content attributes.

Let's first define what the constraints for these attributes should be:

  • Title: minimum 3 characters, and maximum 70 characters.
  • Introduction one sentence: maybe 25 characters? I don't think we need a maximum here
  • Content one sentence as well: 25 characters. Again, we don't want a maximum here.

Now we can define how we want the validation to work. We probably want to get a list of errors. It's more useful to know about all attributes whether they are invalid than just the first one. We can define this in the test by catching the InvalidBlogPost exception and assert that we get three errors:

/** @test */
public function a_blog_post_must_be_valid()
{
    try {
        new BlogPost(
            author: new Author('Mark'),
            category: new Category('PHP'),
            title: '',
            introduction: '',
            content: ''
        );
        $this->fail('Expected InvalidBlogPost exception: BlogPost is invalid');
    } catch (InvalidBlogPost $e) {
        $this->assertCount(3, $e->getErrors());
    }
}

And we of course need to test the individual edge cases:

/** @test */
public function the_title_must_be_minimum_3_characters()
{
    $this->expectException(InvalidBlogPost::class);

    new BlogPost(
        author: new Author('Mark'),
        category: new Category('PHP'),
        title: 'Oh',
        introduction: 'A short introduction to the BlogPost',
        content: 'The content of the full article',
    );
}

/** @test */
public function the_title_must_be_maximum_70_characters()
{
    $this->expectException(InvalidBlogPost::class);

    new BlogPost(
        author: new Author('Mark'),
        category: new Category('PHP'),
        title: 'A title that is way too long for a sane Blog post. This sholud be invalid!',
        introduction: 'A short introduction to the BlogPost',
        content: 'The content of the full article',
    );
}

/** @test */
public function the_introduction_must_be_minimum_25_characters()
{
    $this->expectException(InvalidBlogPost::class);

    new BlogPost(
        author: new Author('Mark'),
        category: new Category('PHP'),
        title: 'My first blog post',
        introduction: 'Too short',
        content: 'The content of the full article',
    );
}

/** @test */
public function the_content_must_be_minimum_25_characters()
{
    $this->expectException(InvalidBlogPost::class);

    new BlogPost(
        author: new Author('Mark'),
        category: new Category('PHP'),
        title: 'My first blog post',
        introduction: 'A short introduction to the BlogPost',
        content: 'Too short',
    );
}

For the implementation we can use the Validation trait again.

public function __construct(
    Author $author,
    Category $category,
    string $title,
    string $introduction,
    string $content,
) {
    $errors = [];
    if ($this->isTooShort($title, self::MIN_TITLE_CHARS) || $this->isTooLong($title, self::MAX_TITLE_CHARS)) {
        $errors['title'] = 'Title must be between 3 and 70 characters';
    }
    if ($this->isTooShort($introduction, self::MIN_INTRODUCTION_CHARS)) {
        $errors['introduction'] = 'Introduction must be minimum 25 characters long';
    }
    if ($this->isTooShort($content, self::MIN_CONTENT_CHARS)) {
        $errors['content'] = 'Content must be minimum 25 characters long';
    }
    if(count($errors) > 0) {
        throw new InvalidBlogPost($errors);
    }

    $this->author = $author;
    $this->category = $category;
    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;
}

We are green. Time for refactoring. The constructor of the BlogPost is becoming quite large and it's doing two things at the same time. We can solve that by extracting the validation code to a validate() method.

protected function validate(string $title,
    string $introduction,
    string $content
): void {
    $errors = [];
    if ($this->isTooShort($title, self::MIN_TITLE_CHARS) || $this->isTooLong($title, self::MAX_TITLE_CHARS)) {
        $errors['title'] = 'Title must be between 3 and 70 characters';
    }
    if ($this->isTooShort($introduction, self::MIN_INTRODUCTION_CHARS)) {
        $errors['introduction'] = 'Introduction must be minimum 25 characters long';
    }
    if ($this->isTooShort($content, self::MIN_CONTENT_CHARS)) {
        $errors['content'] = 'Content must be minimum 25 characters long';
    }
    if (count($errors) > 0) {
        throw new InvalidBlogPost($errors);
    }
}

public function __construct(
    Author $author,
    Category $category,
    string $title,
    string $introduction,
    string $content,
) {
    $this->validate($title, $introduction, $content);

    $this->author = $author;
    $this->category = $category;
    $this->title = $title;
    $this->introduction = $introduction;
    $this->content = $content;
}

Anything else? We could extract the validation code to a separate class, but that feels like overkill. I'm actually pretty OK with the current code.

I'm not so happy about the test. Every test method is creating a BlogPost with just one different parameter. We are repeating ouselves and we wouldn't do that in our normal code. We shouldn't do that in our test code either. We can solve this by extracting a createBlogPost() method that creates a new BlogPost based on default values. We allow the user of this method to overwrite the defaults using the method parameters:

<?php

namespace Tests;

use PHPUnit\Framework\TestCase;
use Webdevils\Blog\Author;
use Webdevils\Blog\BlogPost;
use Webdevils\Blog\Category;
use Webdevils\Blog\Exceptions\InvalidBlogPost;

class BlogPostTest extends TestCase
{
    protected function createBlogPost(string $title = 'My first blog post',
        string $introduction = 'A short introduction to the BlogPost',
        string $content = 'The content of the full article',
    ): BlogPost {
        return new BlogPost(
            author: new Author('Mark'),
            category: new Category('PHP'),
            title: $title,
            introduction: $introduction,
            content: $content,
        );
    }

    /** @test */public function can_create_a_new_blogpost()
    {
        $blogPost = $this->createBlogPost();

        $this->assertEquals(new Author('Mark'), $blogPost->getAuthor());
        $this->assertEquals(new Category('PHP'), $blogPost->getCategory());
        $this->assertEquals('My first blog post', $blogPost->getTitle());
        $this->assertEquals('A short introduction to the BlogPost', $blogPost->getIntroduction());
        $this->assertEquals('The content of the full article', $blogPost->getContent());
    }

    /** @test */public function a_blog_post_must_be_valid()
    {
        try {
            $this->createBlogPost(
                title: '',
                introduction: '',
                content: '',
            );

            $this->fail('Expected InvalidBlogPost exception: BlogPost is invalid');
        } catch (InvalidBlogPost $e) {
            $this->assertCount(3, $e->getErrors());
        }
    }

    /** @test */public function the_title_must_be_minimum_3_characters()
    {
        $this->expectException(InvalidBlogPost::class);

        $this->createBlogPost(title: 'Oh');
    }

    /** @test */public function the_title_must_be_maximum_70_characters()
    {
        $this->expectException(InvalidBlogPost::class);

        $this->createBlogPost(title: 'A title that is way too long for a sane Blog post. This sholud be invalid!');
    }

    /** @test */public function the_introduction_must_be_minimum_25_characters()
    {
        $this->expectException(InvalidBlogPost::class);

        $this->createBlogPost(introduction: 'Too short');
    }

    /** @test */public function the_content_must_be_minimum_25_characters()
    {
        $this->expectException(InvalidBlogPost::class);

        $this->createBlogPost(content: 'Too short');
    }
}

That looks much cleaner and shorter.

That's it for today. We have made quite some progress: we can create a Category, Author and BlogPost. The classes only allow you to create a valid version and will throw an Exception with error messages when you try to create an invalid version of them.

I made the code of the Blog available through Github for everyone that wants to read through the final code:

Next article we will bring some more business logic to the BlogPost class by implementing the different statuses: Draft, Published and Scheduled.

Author

Mark Kazemier's avatar
Mark Kazemier

Hi, my name is Mark. I'm the founder of webdevils.nl and love developing websites and other web applications. Through Webdevils.nl I want to spread my enthousiasm about the web and PHP. In my professional live I'm a security expert specialised in security monitoring.

View all posts