Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[11.x] Constrain key when asserting database has a model #52464

Merged
merged 3 commits into from
Aug 16, 2024

Conversation

patrickomeara
Copy link
Contributor

There are currently three options to pass as the first argument to assertDatabaseHas() and assertDatabaseMissing()

  • the table name (string)
  • the class (string)
  • a model (instance)
$pat = User::factory()->create(['name' => 'Pat']);

$this->assertDatabaseHas('users', ['name' => 'Pat']); // pass
$this->assertDatabaseHas(User::class, ['name' => 'Pat']); // pass

$cinderella = User::factory()->create(['name' => 'Cinderella']);

$this->assertDatabaseHas($cinderella, ['name' => 'Pat']); // pass

This PR adds a constraint on the model's key when an eloquent model is used so the last assertion above would fail.

Breaking Change

Like the above example, if a test suite currently asserts a database row contains data against a different model instance that test will now fail. However, I would argue that the test gives a false positive and should change to use the class string or the correct model instance.

$this->assertDatabaseHas(User::class, ['name' => 'Pat']); // pass

$this->assertDatabaseHas($pat, ['name' => 'Pat']); // pass

Additionally, passing an empty model instance is an anti-pattern as until it is saved, the database does not have that model.

Currently the following test would pass:

$pat = User::query()->find(1);
$cinderella = new User(['name' => 'Cinderella']);

$this->assertDatabaseHas($cinderella, ['name' => 'Pat']); // pass
$this->assertDatabaseMissing($pat, ['name' => 'Cinderella']); // pass

// Using User::class for these has a clearer intention.
$this->assertDatabaseHas(new User, ['name' => 'Pat']); // pass
$this->assertDatabaseMissing(new User, ['name' => 'Cinderella']); // pass

This change will only break those specific tests not the application logic.

@devfrey
Copy link
Contributor

devfrey commented Aug 13, 2024

I like the idea, but this is breaking and shouldn't be targetted at 11.x. Changing the signature of HasInDatabase::__construct() alone is breaking.

I also don't think it makes sense to make $data optional in assertDatabaseHas() and assertDatabaseMissing(). We already have assertModelExists() and assertModelMissing() respectively, that assert without the data.

@patrickomeara
Copy link
Contributor Author

patrickomeara commented Aug 14, 2024

The breaking changes are not in the application layer and would only affect those that extend HasInDatabase or those that could afford to have some clearer intention around their tests. ie using the class instead of a new instance, or using the correct instance.

This change will also uncover any false positives that may exist in a test suite due to those reasons.

This could easily target the next major release, however it needs to be weighed up on the additional work load around that release (upgrade docs, conflicts, five months of rebasing) versus how many tests this will affect. Remembering that it's a test only change, no application will break from this change.

It's not uncommon for a minor release to break tests, here are some examples I can think of:

Anyone using a new model instance can replace it with the class string using the following regex.

s/->assertDatabase(Has|Missing)\((\s*)new ([\w\\]+)(?:\(\))?,/->assertDatabase$1\($2$3::class,/

Regarding, assertModelExists() and assertModelMissing(); they are both database checks but that isn't overly clear. Is it just checking the exists attribute of the model?

$user = new User(['exists' => true]);

$this->assertModelExists($user); // am I using this correctly?

$this->assertDatabaseHas($user); // fail, much clearer

A common pattern I see in feature tests is making a model using a factory, sending the request then refreshing the instance and asserting it's attributes, this cleans that up.

// arrange
$user = User::factory()->create(['name' => 'Pat', 'role' => 'developer']);

// act
$this->patchJson(route('users.update', $user), ['name' => 'Cinderella'])
    ->assertOk();

// refresh and assert
$user->refresh();
$this->assertSame('Cinderella', $user->name);
$this->assertSame('developer', $user->role);

// or just assert the database has it
$this->assertDatabaseHas($user, ['name' => 'Cinderella', 'role' => 'developer']);

@samlev
Copy link
Contributor

samlev commented Aug 14, 2024

People can also use $model::class if they want a minimal change to their tests.

* @return void
*/
public function __construct(Connection $database, array $data)
public function __construct(Connection $database, array $data, $model)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of changing the signature here, would it be better to do something like this in the assert methods?

if ($connection instanceof Model && $connection->getKey()) {
    $data = array_merge($data, [$connection->getKeyName() => $connection->getKey()]);
}

So that it's just injecting the model's key into the data that it's checking for if it isn't already set.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's definitely possible, but would result in duplicate code in both assertDatabaseHas() and assertDatabaseMissing() methods, as all roads lead to HasInDatabase I moved it there.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also don't really love injecting $model here. Doesn't feel like this class should have anything to do with Eloquent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, let me refactor this a little.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have left HasInDatabase as is. This should be a little more palatable. Thanks for the push in the right direction @samlev

@taylorotwell taylorotwell marked this pull request as draft August 15, 2024 18:11
@patrickomeara patrickomeara marked this pull request as ready for review August 15, 2024 22:40
@taylorotwell taylorotwell merged commit 451bc21 into laravel:11.x Aug 16, 2024
29 checks passed
@taylorotwell
Copy link
Member

Thanks 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants