Sam Ringleman
Posted on May 25, 2019
So I am relatively new to the coding world. I have been a professional engineer now for a little over three years. Happily, I recently accepted a new position as a mid-level engineer, yay! I have learned quite a lot by taking a new position, one of the more prominent items to stand out in my mind is testing.
At a previous job, we were on the mindset that testing would slow us down. There were some strong opponents of testing. The most prominent argument that I heard was that testing took too much time. There is the time investment in writing the tests. There is the time investment running the tests. There is the time investment maintaining the tests. While these arguments have a point, you do spend time setting everything up. I counter this by saying the time investment is minor. The benefits that you gain through testing far outweigh the time invested upfront. I have experienced this first hand through testing my model relationships in Laravel.
My previous workflow for testing relationships was a bit awkward and clunky. I would do the database migration first, and in our situation, that was a straight SQL query to a testing db. Then go in the models and create the relationship (hasOne, hasMany, etc). Then to make sure the relationship is built right, I write a test controller. This controller has a test endpoint, oh and let's not forget a test route. The test controller action was simple enough, I don't need input from a user, but I do need a model ID. If that ID exists in the database I jump over to the database find an id for each model and join them in the controller action... If there was no ID I create a model and grab that ID. Then after I get my test controller action set up, I hop over into Postman fire off the request. Low and behold my test action borks. Hmm, I must have screwed something up in the models. Jump into bug tracking mode. I hop around the models, look at the db schema, back to routes file, and oh shoot, there it is, a mistake in my test action. Fix the small issue, then back into Postman, and yay it works. Now to finish hooking up the relationship in the code that matters. Then delete the test controller and routes and be on my merry way. This is a slow and clunky workflow, that is very error prone.
Fast forward to the new job, new lead. We do a lot of testing, unit, integration, feature, even UAT!
My new workflow goes like this. I design my schema and write a migration. Then I write a test for the relationship I want between the models. I then go into the model, and code the relationship. And I run my test. That's it.
Pretty lame post huh? Nah, let's go through an example together. I am using Lumen in this example. All the code is here, so if you want to follow along, install a fresh Lumen project and let's build some guitars!
Start off with the models schema and migrations. Run these two commands in your projects directory:
php artisan create:migration create_brands_table
php artisan create:migration create_guitars_table
Here is what our migrations are going to look like.
class CreateBrandsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('brands', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('brands');
}
}
class CreateGuitarsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('guitars', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('name');
$table->string('type');
$table->unsignedBigInteger('brand_id');
$table->foreign('brand_id')->references('id')->on('brands');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('guitars');
}
}
Two very simple models and the relationship that we are designing here is a one to many. A guitar has one brand, and a brand can have many guitars.
Let's create our models now. Our Brand
model looks like this:
class Brand extends Model
{
/**
* @var string
*/
protected $table = 'brands';
/**
* @var array
*/
protected $fillable = ['name'];
}
And our Guitar
model looks like this:
class Guitar extends Model
{
/**
* @var string
*/
protected $table = 'guitars';
/**
* @var array
*/
protected $fillable = ['name', 'type'];
}
Again, nothing complicated, but now we get into the fun part. I like to write my tests before I write out my logic. So let's get started by writing the test for the Guitar
to have one Brand
.
Create the file GuitarTest
in your testing directory. And add the first test it_should_have_one_brand
:
class GuitarTest extends TestCase
{
/**
* @test
*/
public function it_should_have_one_brand()
{
}
}
We need to have a guitar instance to run our tests. When it comes to testing Eloquent models it makes sense to use them with the database. So let's create a factory for the Guitar
and Brand
models.
In our ModelFactory
file insert this:
$factory->define(App\Models\Guitar::class, function (Faker\Generator $faker) {
return [
'name' => $faker->name,
'type' => $faker->randomElement(['acoustic', 'electric']),
];
});
$factory->define(App\Models\Brand::class, function (Faker\Generator $faker) {
return [
'name' => $faker->name,
];
});
Great! Now we can start the testing! So jump back to the GuitarTest
that we started earlier and let's create our models.
public function it_should_have_one_brand()
{
$guitar = factory(\App\Models\Guitar::class)->create();
$brand = factory(\App\Models\Brand::class)->create();
}
If you run your test here you are going to get an SQL error.
Illuminate\Database\QueryException : SQLSTATE[HY000]: General error: 1364 Field 'brand_id' doesn't have a default value (SQL: insert into `guitars` (`name`, `type`, `updated_at`, `created_at`) values (Jaydon Daugherty, acoustic, 2019-05-23 22:59:12, 2019-05-23 22:59:12))
Uh oh, turns out that we cannot create a Guitar
at this point without first having a Brand
. That is good news, we know that our schema is working. There are a few options available to use here. We can implement a factory state which will create a Brand
for us. We could change the schema and make the foreign key nullable. Or we can not create the model and instantiate it.
Let's choose the latter and and leave our schema alone.
The create
method on the factory creates your model and persists it to the database. The make
method instantiates it and fills it with data leaving you to do the persisting. The reason that we got the SQL error is because of the create method. At that point we were trying to save the model, without giving a value to the foreign key that we created. We also did not specify that it was nullable. It does not make sense to me to have a Guitar
without a Brand
in this process (homebuilders, I feel you). So we get an error without supplying a value.
So what do we do? This:
public function it_should_have_one_brand()
{
$guitar = factory(\App\Models\Guitar::class)->make();
$brand = factory(\App\Models\Brand::class)->create();
$guitar->brand()->associate($brand);
$guitar->save();
}
Please note that we changed the Guitar
factory call from create
to make
.
Now to finish the test, add an assertion to make sure that we have the correct data, and this is a good test!
public function it_should_have_one_brand()
{
$guitar = factory(\App\Models\Guitar::class)->make();
$brand = factory(\App\Models\Brand::class)->create();
$guitar->brand()->associate($brand);
$guitar->save();
$this->assertInstanceOf(\App\Models\Brand::class, $guitar->brand);
}
Now run your test, you should get an error.
BadMethodCallException : Call to undefined method App\Models\Guitar::brand()
Which makes perfect sense, we haven't implemented the relationship yet. Let's do that. Head back into the Guitar model and add:
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function brand()
{
return $this->belongsTo(Brand::class);
}
And run the test again. You should be all green now!
We can go a little further with our assertion if we want to make sure that the guitar did save the same model. Note that this is optional, this test is fine as is.
The completed test looks like this:
/**
* @test
*/
public function it_should_have_one_brand()
{
$guitar = factory(\App\Models\Guitar::class)->make();
$brand = factory(\App\Models\Brand::class)->create();
$guitar->brand()->associate($brand);
$guitar->save();
$this->assertInstanceOf(\App\Models\Brand::class, $guitar->brand);
$this->assertEquals($brand->id, $guitar->brand->id);
}
Great so we have our relationships tested right? Wrong. We have one way of a two-way relationship tested. Let's hook up our Brand model to their many Guitar models.
Create the BrandsTest in your testing directory and write the first test.
class BrandsTest extends TestCase
{
/**
* @test
*/
public function it_should_have_many_guitars()
{
}
}
Remember that we have a one to many relationship here, so a brand can have many guitars. And that is what we are going to test for. Let's make our models:
public function it_should_have_many_guitars()
{
$brand = factory(\App\Models\Brand::class)->create();
$guitars = factory(\App\Models\Guitar::class, 5)->make();
}
Then we need to associate them and run our assertions against the relationship. So our final test is going to look like this:
public function it_should_have_many_guitars()
{
$brand = factory(\App\Models\Brand::class)->create();
$guitars = factory(\App\Models\Guitar::class, 5)->make();
$brand->guitars()->saveMany($guitars);
$this->assertCount(5, $brand->guitars);
$this->assertContainsOnlyInstancesOf(\App\Models\Guitar::class, $brand->guitars);
}
Now when we run this test, like last time, we are going to get the error:
BadMethodCallException : Call to undefined method App\Models\Brand::guitars()
Again, it makes perfect sense, we haven't implemented that logic in our model yet. Add the guitars
method to your Brand
model.
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function guitars()
{
return $this->hasMany(Guitar::class);
}
Now run your tests, and if you followed along with everything, you should have two passing tests!
These tests to me are worth their weight in gold. Your application is going to grow and get more complex. Stick with adding your factories and then testing the relationships. You have tests to make sure that things are never failing. Once you get into a good flow, it takes 10 minutes to set up a factory, then build your test, and the relationship. Once your tests pass, you are good to write any logic surrounding these relationships.
You are going to thank yourself when you build a more complex relationship. Polymorphic many to many relationships are difficult to grasp but easy to test. The tests however, are the same as above. You now know that you set up your database schema right. You get the peace of mind knowing that the code you wrote, and are going to push to production on Friday, is at least tested. 😉
Posted on May 25, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.