Search code examples
laravelautomated-testsphpunit

How to mock methods called inside a controller method while testing laravel controller methods


The route defined in web.php is

Route::get('about-us', [PageController::class, 'renderAboutUsPage'])->name('pages.about-us');

In my controller, I have methods

class PageController extends Controller
{
    protected $pageService;

    /**
     * Class contructor
     */
    public function __construct(PageService $pageService)
    {
        $this->pageService = $pageService;
    }

    /**
     * Method to render about us page
     */
    public function renderAboutUsPage(){
        return $this->renderStaticPage('about-us');
    }

    /**
     * Method to render static page
     * @param slug
     */
    private function renderStaticPage($slug) {
        Log::info("Rendering ".$slug." static page.");
        $page = $this->pageService->getActivePageBySlug($slug);
        return view('pages.static-page', ['data'=>$page]);
    }
}

What my understanding says when I test the method renderAboutUsPage() then I should mock pageService->getActivePageBySlug($slug) in my test so that a real call to this method can be avoided. it will help reducing the test execution time.

I have separate tests for my service where I am testing getActivePageBySlug() independently.

My test case is

    /**
     * @test
     * @testdox Whether the about us page returns a successful response, renders the correct view, contains view object {data} and log correct messages in log file.
     * @group website
     * @group static-pages
     */
    public function test_whether_about_us_page_renders_successfully()
    {
        Log::shouldReceive('info')->once()->with("Rendering about-us static page.");
        Log::shouldReceive('info')->once()->with("Getting page by active status and provided slug.");
        $response = $this->get('about-us');
        $response->assertStatus(200);
        $response->assertViewIs('pages.static-page'); 
        $response->assertViewHas('data');
    }

I do not know how to mock the method getActivePageBySlug($slug) in my test case to avoid real call.

The definition of getActivePageBySlug($slug) method is:

    /**
     * Method to get page by slug (Checks to page status is active)
     * @param string slug
     * @return Page page
     * @throws Throwable e 
     */
    public function getActivePageBySlug(string $slug)
    {
        Log::info("Getting page by active status and provided slug.");
        try
        {
            $page = Page::where('slug',$slug)->where('status', Status::active->value())->first();
            if(!$page) {
                throw new NotFoundHttpException("The page ". $slug ." not found.");
            } 
            return $page;
        }
        catch (Throwable $e)
        {
            Log::error("Error in getting page by slug.");
            throw $e;
        }
    }

Solution

  • In reality, you don't want to avoid the call, else it is not really a feature test but a "unit test". And if you are testing an endpoint/url in your project, it should be a feature test, you should do the real call unless it is a third-party service where you really want to avoid the call.

    Your service is doing a database call, you should call it, it is fine, especially that you are not doing any heavy lifting after it.

    So, if you want to do the real call, you should have the code like this (supposing you have factories and everything correctly set up):

    /**
     * @group website
     * @group static-pages
     */
    public function test_about_us_page_should_render(): void
    {
        Page::factory()->create([
            'slug' => $page = 'about-us',
            'status' => Status::active, // Be sure to cast 'status' => Status::class in your model
        ]);
    
        Log::shouldReceive('info')->once()->with("Rendering {$page} static page.");
        Log::shouldReceive('info')->once()->with("Getting page by active status and provided slug.");
    
        $this->get($page)
            ->assertOk()
            ->assertViewIs('pages.static-page')
            ->assertViewHas('data');
    }
    

    That should be the most simple test ever, you could add more stuff there. See that I changed the test name (trying to follow standards), and other small changes.

    Now, let's say you also expect the page to fail (your service throws an error), so let's test that too:

    /**
     * @group website
     * @group static-pages
     * @depends test_about_us_page_should_render
     * @dataProvider pageErrorDataProvider
     */
    public function test_about_us_page_should_throw_an_error_when_model_not_found(callable $pageSeeder): void
    {
        $pageSeeder();
    
        $page = 'about-us';
    
        Log::shouldReceive('error')->once()->with("Error in getting page by slug.");
    
        $this->get($page)
            ->assertServerError(); // This checks for status = 500
    
        // You should also assert that you got the exact error: "The page {$page} not found."
    }
    
    public function pageErrorDataProvider(): array
    {
        return [
            'Page not active' => function () {
                return Page::factory()->create([
                    'slug' => 'about-us',
                    'status' => Status::disabled, // Or whatever the other state is
                ]);
            },
            'Different page' => function () {
                return Page::factory()->create([
                    'slug' => 'about',
                    'status' => Status::active,
                ]);
            },
            'Page does not exist' => function () {
                return null;
            },
        ];
    }
    

    Now, you can see that we are first of all depending on the first test, if the happy path works, this new case can also run. We are also taking advantage of data providers, so we test different possibilities with the same case, just different data initialization.

    I do not remember right now what is the right assertion for also checking the error from the endpoint, so we also make sure to check that the error is the expected error, but please, read the official documentation and tinker with it and you will find it.

    Finally, if you really want to avoid doing any call, this should your test be:

    /**
     * @group website
     * @group static-pages
     */
    public function test_about_us_page_should_render(): void
    {
        $page = 'about-us';
    
        $this->mock(PageService::class, function (MockInterface $mock) {
            $mock->shouldReceive('getActivePageBySlug')
                ->andReturn(
                    Page::factory()->make([
                        'slug' => $page = 'about-us',
                        'status' => Status::active,
                    ])
                );
        });
    
        Log::shouldReceive('info')->once()->with("Rendering {$page} static page.");
        Log::shouldReceive('info')->once()->with("Getting page by active status and provided slug.");
    
        $this->get($page)
            ->assertOk()
            ->assertViewIs('pages.static-page')
            ->assertViewHas('data');
    }
    

    You want to mock the service, so you can do anything.

    Have in mind that I used Laravel 11.x documentation, and I have no idea what content does your Page have, you should also assert that the returned content is the expected one, not only that it has data index in your view, but also what data it is.