DEV Community

Cover image for Test Driven Development in Laravel - Folder Structure & Setup (1)
Bedram Tamang
Bedram Tamang

Posted on

1 1 1 1 1

Test Driven Development in Laravel - Folder Structure & Setup (1)

Folder structure

In this article, we will explore how to organize tests effectively, using the example of a feature that allows users to write and publish a blog post. Consider an API endpoint that allows authenticated users to add a new post. There are various way to sturcture your tests, for instance you could create a Feature/PostTest class and write all the test realted to a feature about creating a post in this class.

Benefits

  • Each feature has its own dedicated test class.
  • All tests related to a specific feature are grouped within a single class.
  • Maintains a flat folder structure without nested levels, simplifying organization.
  • Ideal for small codebases and teams with fewer members.

Drawbacks of this structure:

  • In larger codebases, it becomes challenging to locate specific test cases related to the code.
  • All the test cases related to one feature will be in single file
  • Difiiculties to mange in larger team or larger code base.

I personally prefer this approach when working alone or in a small team. However, in larger codebases, it can be challenging to group and identify tests for each feature effectively, making this structure harder to maintain.

Another approach of organizaing folder strucure is to create a Test class that mirrors the directory of the corrosponding code files but resides within the tests folder, for example, if a controller is located at src/application/controllers/BlogController.php the test for this controller will be written in tests/Feature/application/controllers/BlogControllerTest.php. In this setup, the Feature prefix refers to complete end-to-end tests.

Benefits

  • Simplifies locating tests for a specific piece of code and vice versa.
  • Enhances organization, making tests easily discoverable, especially in larger teams.

Test Setup

To create a new Laravel application, use the command laravel new laravel-tdd. During the setup, you'll be prompted to select a testing framework. For simplicity, I chose PHPUnit as the testing framework and sqlite as the database. Laravel includes a basic test setup by default, and you can run your tests using php artisan test. By default, it runs and passes two tests.

Database Setup

Consider a feature, where user should able to write a blog post and publish it. Consider a blog contains only title and body for now for simplicity. To implement this feature we need database table, Model and Controller, so Let's quickly create a database model, migrations, factory and controller with this command php artisan make:model Blog -mfcr. The following files are created below:

  • app/Http/Controllers/BlogController.php
  • app/Models/Blog.php
  • database/migrations/2025_01_22_195944_create_blogs_table.php
  • database/factories/BlogFactory.php

Let's add title and body into our migration.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('blogs', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->longText('body')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('blogs');
    }
};
Enter fullscreen mode Exit fullscreen mode

we need to add these fields are fillable in our model:

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Blog extends Model
{
    /** @use HasFactory<\Database\Factories\BlogFactory> */
    use HasFactory;

    protected $fillable = [
        'title',
        'body'
    ];
}
Enter fullscreen mode Exit fullscreen mode

We added the following route in web.php

use App\Http\Controllers\BlogController;

Route::post('blogs', [BlogController::class, 'store'])->name('blogs.store');
Enter fullscreen mode Exit fullscreen mode

and we also implemented the store method in BlogController as:

class BlogController extends Controller
{
    ...

    public function store(Request $request)
    {
        Blog::query()->create($request->all());

        return redirect()->back()->with('success', 'Blog created successfully');
    }

    ...
}   
Enter fullscreen mode Exit fullscreen mode

The code above processes data from a POST request and saves it to the blogs database table. Now, let’s write an integration test to ensure the feature works as expected by verifying:

  • A user can successfully submit a request.
  • The submitted data is correctly stored in the database.

Create a test class Tests\Feature\Http\Controllers\BlogControllerTest. A basic test for this feature would look like this:

<?php

namespace Tests\Feature\Http\Controllers;

use Tests\TestCase;

class BlogControllerTest extends TestCase
{
    public function testUserCanCreateABlogPost(): void
    {
        $this->post(route('blog.store'), [
            "title"       => "Blog title",
            "body" => "Blog description",
        ]);

        $this->assertDatabaseHas("blogs", [
            "title"       => "Blog title",
            "body" => "Blog description",
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 1 no such table: blogs (Connection: sqlite, SQL: select exists(select * from "blogs" where ("title" = Blog title and "body" = Blog description)) as "exists")

We got exception as we haven't setup our database yet. The easiest things to do is uncomment these two lines in phpunit.xml file

<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/> 
Enter fullscreen mode Exit fullscreen mode

We are telling our PHPUnit to use in memory sqlite as our database. and add use \Illuminate\Foundation\Testing\RefreshDatabase; as trait in our Tests\Feature\Http\Controllers\BlogControllerTest class. Our complete class would look like:

<?php

namespace Tests\Feature\Http\Controllers;

use Tests\TestCase;

class BlogControllerTest extends TestCase
{
    use \Illuminate\Foundation\Testing\RefreshDatabase;

    public function testUserCanCreateABlogPost(): void
    {
        $this->post(route('blogs.store'), [
            "title"       => "Blog title",
            "body"              => "Blog description",
        ]);

        $this->assertDatabaseHas("blogs", [
            "title"       => "Blog title",
            "body"              => "Blog description",
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Congratulations our first test passed.

Conclusion

In this guide, we implemented a feature that allows users to write and publish blog posts. We structured the project step by step, from creating the necessary database table, model, and controller to setting up routes and implementing the logic to handle POST requests.

We also demonstrated how to write an integration test to verify the functionality of the feature, ensuring that:

  • A user can successfully submit a request.
  • The submitted data is correctly stored in the database.

Through this process, we encountered and resolved common challenges, such as setting up the database for testing. By using an in-memory SQLite database and the RefreshDatabase trait, we streamlined the test environment to ensure reliable and efficient test execution.

This approach highlights the importance of structured development and testing in Laravel, ensuring that features are both functional and maintainable. By passing our first test, we’ve laid the foundation for building robust, well-tested features in our application.

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read more

Top comments (2)

Collapse
 
xwero profile image
david duymelinck

While it is an article about test file structure, I think you give a bad example of a test.

Saving data in the database is the task of the ORM because the example uses a model. Laravel aready have a lot of Eloquent tests.
If you want to do that test it is not needed to use the route, you can do;

public function testUserCanCreateABlogPost(): void
    {
         Blog::query()->create([
            "title"       => "Blog title",
            "body"              => "Blog description",
        ]);
   }
Enter fullscreen mode Exit fullscreen mode

This has less dependencies.

The things you want to test in your application is the custom logic that is added.

For example when there is a check to be sure the Blog as been created or not, and you added messages accordingly. The tests will target the scenarios to get those messages.
If you want to show how PHPUnit sets up a database for test, you can create a class with a save method that has a sql query.

The main point I'm trying to make is when you have examples use a real scenario. People, including myself, like to copy things. And when they see bad examples they will think it is ok to do it.

Collapse
 
bedram-tamang profile image
Bedram Tamang

Thanks for the feedback.

I agree that this article mixes two topics: folder structures for organizing tests and setting up the test environment. I explained the folder structure in the Folder structure section and then started another part of the blog, which is Setting up tests. I provided a real-world example of how to set up your database with PHPUnit. However, it might be a bit advanced for beginners to understand that I am performing a full integration test to demonstrate how we can assert that users can make a POST request to the application and have the data in the request saved to the database.

I also agree that this test is not yet complete, as I plan to write follow-up articles covering topics like validations, JSON responses, and other aspects. These future articles will include reusable code that readers can copy and use directly.

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more