How to test a service class?

Hello!

I have written a PostUpdater service in Slim as follows:

<?php

declare(strict_types=1);

namespace App\Domain\Post\Service;

use App\Domain\Post\Data\Post;
use App\Domain\Post\Repository\PostUpdaterRepository;
use App\Factory\LoggerFactory;
use App\Support\Slug;
use Cake\Validation\Validator;
use Error;
use Exception;
use Psr\Log\LoggerInterface;
use Selective\ArrayReader\ArrayReader;

final class PostUpdater
{
    /**
     * @Injection
     * @var LoggerInterface
     */
    private LoggerInterface $logger;

    /**
     * @Injection
     * @var PostUpdaterRepository
     */
    private PostUpdaterRepository $repository;

    /**
     * @Injection
     * @var Validator
     */
    private Validator $validator;

    /**
     * The constructor.
     *
     * @param LoggerFactory $loggerFactory Monolog logger factory.
     * @param PostUpdaterRepository $repository Post updater repository
     * @param Validator $validator CakePHP validator
     */
    public function __construct(
        LoggerFactory $loggerFactory,
        PostUpdaterRepository $repository,
        Validator $validator
    ) {
        $this->logger = $loggerFactory->addFileHandler('error.log')->createLogger();
        $this->repository = $repository;
        $this->validator = $validator;
    }

    /**
     * Update post entry.
     *
     * @param array<string> $formData The form data
     *
     * @return bool
     */
    public function update(array $formData): bool
    {
        $this->validate($formData);

        $post = $this->setPost($formData);

        try {
            $this->repository->update($post);

            return true;
        } catch (Exception $e) {
            $this->logger->error(sprintf("PostFinder->update(): %s", $e->getMessage()));

            return false;
        } catch (Error $e) {
            $this->logger->error(sprintf("PostFinder->update(): %s", $e->getMessage()));
            
            return false;
        }
    }

    /**
     * Create a slug by a post title.
     *
     * @param string $title Post title
     *
     * @return string
     */
    private function slug(string $title): string
    {
        $slug = new Slug('german');

        $slugify = $slug->slugify($title);

        return $slugify . '.html';
    }

    /**
     * Create a post object from form data.
     * 
     * @param array<string> $formData The form data.
     * 
     * @return Post
     */
    private function setPost(array $formData): Post
    {
        $reader = new ArrayReader($formData);
        
        $id = $reader->findInt('id');
        $title = $reader->findString('title');
        $intro = $reader->findString('intro');
        $content = $reader->findString('content');
        $authorId = $reader->findInt('author_id');
        $onMainpage = $reader->findBool('on_mainpage');
        $isPublished = $reader->findBool('is_published');
        $createdAt = $reader->findString('created_at');

        $slug = $this->slug($title);

        if ($isPublished)
            $publishedAt = date('Y-m-d H:i:s');

        $updatedAt = date('Y-m-d H:i:s');

        $post = new Post(
            $id,
            $title,
            $slug,
            $intro,
            $content,
            $authorId,
            $onMainpage,
            $publishedAt,
            $isPublished,
            $createdAt,
            $updatedAt
        );

        return $post;
    }

    /**
     * Validate the form data.
     *
     * @param array<mixed> $formData The form data.
     *
     * @return void
     */
    public function validate(array $formData): void
    {
        $this->validator
            ->requirePresence('title')
            ->notEmptyString('title', 'Der Titel darf nicht leer sein.');
        
        $errors = $this->validator->validate($formData);

        if ($errors) {
            foreach ($errors as $error) {
                dd($error);
            }
        }
    }
}

Because I keep having problems with these services, I would like to write tests for them. I just don’t know exactly how and what makes sense.

More precisely, I fail because I can’t instantiate a PostUpdater object because it requires several parameters. LoggerFactory, PostUpdaterRepository, Validator.

I got my first ideas with Copilot:

<?php

declare(strict_types=1);

namespace Tests\Domain\Posts\Post;

use App\Domain\Post\Repository\PostUpdaterRepository;
use App\Domain\Post\Service\PostUpdater;
use App\Factory\LoggerFactory;
use Cake\Validation\Validator;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use ReflectionClass;

class PostUpdaterTest extends TestCase
{
    private PostUpdaterRepository $repository;
    private Validator $validator;

    public function setUp(): void
    {
        $this->repository = $this->createMock(PostUpdaterRepository::class);
        $this->validator = $this->createMock(Validator::class);

        $logger = $this->createMock(LoggerInterface::class);
        $loggerFactory = $this->getMockBuilder(LoggerFactory::class)
            ->disableOriginalConstructor()
            ->getMock();
        $loggerFactory->method('AddFileHandler')->willReturnSelf();
        $loggerFactory->method('createLogger')->willReturn($logger);

        $this->postUpdater = new PostUpdater($loggerFactory, $this->repository, $this->validator);
    }
...
}

Which doesn’t work because PostUpdater expects a LoggerFactory of type App\Factory\LoggerFactory and gets a PHPUnit\Framework\MockObject\MockObject.

So what is the right way to test such a service?

I would be very grateful for any tips.

With kind regards

:slight_smile:

Hi!

You might try to add an interface for you LoggerFactory, for example LoggerFactoryInterface. So you can easily mock the logger factory in your tests.

<?php

declare(strict_types=1);

namespace App\Factory;

use Psr\Log\LoggerInterface;

interface LoggerFactoryInterface
{
    public function addFileHandler(string $filePath): self;
    public function createLogger(): LoggerInterface;
}

Then add that LoggerFactoryInterface to the LoggerFactory.

class LoggerFactory implements LoggerFactoryInterface
{
    // ...
}

Then update the service to depend on LoggerFactoryInterface instead of LoggerFactory:

final class PostUpdater
{
    private LoggerInterface $logger;
    private PostUpdaterRepository $repository;
    private Validator $validator;

    public function __construct(
        LoggerFactoryInterface $loggerFactory,
        PostUpdaterRepository $repository,
        Validator $validator
    ) {
        $this->logger = $loggerFactory->addFileHandler('error.log')->createLogger();
        $this->repository = $repository;
        $this->validator = $validator;
    }
    //...

Then, modify your unit test to mock LoggerFactoryInterface instead of LoggerFactory:

$logger = $this->createMock(LoggerInterface::class);

$loggerFactory = $this->createMock(LoggerFactoryInterface::class);
$loggerFactory->method('addFileHandler')->willReturnSelf();
$loggerFactory->method('createLogger')->willReturn($logger);

PS: Another approach would be to write integration tests by invoking the endpoints and inspecting the results (response). This would be easier to write and allows you to refactor your services classes without breaking the tests. On the other hand, you then need to setup a test database to run the queries.

Hello Odan!

Thanks for your help! It works :slight_smile:

Just like you said it would. I’ll have to look into the integration tests as well. At the moment I want to test individual methods.

Thank you very much!