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');
}
};
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'
];
}
We added the following route in web.php
use App\Http\Controllers\BlogController;
Route::post('blogs', [BlogController::class, 'store'])->name('blogs.store');
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');
}
...
}
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",
]);
}
}
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:"/>
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",
]);
}
}
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.
Top comments (2)
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;
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.
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.