BernardoBF4's avatar

Help understanding if my feature tests should be unit tests

I have been reading about tests and I have found some texts saying I should have more unit tests than feature tests; something called the Test Pyramid.

I've started a simple system using TDD and so far I've basically written feature tests (example bellow), because I understood they mattered the most, since they are more related to what the user really does, such as making a post request (clicking a button that does it) and receiving a response. But because of what I read, I am now in doubt to wether I should keep doing this or if I should create unit tests that are going to test the service methods I call in my controllers.

And even if wrote unit test for each of my service methods, I'd still have more, or equal, feature tests than unit tests, because each method of my services is related to one endpoint and the user can send different data to it.

Also, If I am understanding test wrong, please, feel free to correct me. Thanks.

Tests:

<?php

namespace Tests\Feature\Cms;

use App\Models\Group;
use App\Models\Modules;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class UsersTest extends TestCase
{

  use WithFaker, RefreshDatabase;

  /** @test */
  public function unauthenticated_users_are_redirected()
  {
    $this->withoutExceptionHandling();

    $response = $this->get(route('cms.users.index'));

    $response->assertRedirect();
  }

  /** @test */
  public function a_user_can_be_created()
  {
    $this->withoutExceptionHandling()->signIn();

    $password = $this->faker->password(6, 12);
    $user_data = [
      'email' => $this->faker->safeEmail(),
      'fk_group_id' => Group::factory()->has(Modules::factory(), 'modules')->create()->id,
      'name' => $this->faker->name(),
      'password' => $password,
      'password_confirmation' => $password,
    ];

    $response = $this->post(route('cms.users.store'), $user_data);

    $response->assertSessionHas('response', cms_response(trans('cms.users.success_create')));
  }

  /** @test */
  public function if_passwords_dont_match_when_creating_a_user_an_error_is_returned()
  {
    $this->signIn();

    $user_data = [
      'email' => $this->faker->safeEmail(),
      'fk_group_id' => Group::factory()->has(Modules::factory(), 'modules')->create()->id,
      'name' => $this->faker->name(),
      'password' => $this->faker->password(6, 12),
      'password_confirmation' => $this->faker->password(6, 12),
    ];

    $response = $this->post(route('cms.users.store'), $user_data);

    $this->checkIfSessionErrorMatchesString('password', 'A senha e confirmação de senha não são iguais.');
    $this->checkIfSessionErrorMatchesString('password_confirmation', 'A senha e confirmação de senha não são iguais.');
  }

  /** @test */
  public function a_user_can_be_updated()
  {
    $this->withoutExceptionHandling()->signIn();

    $user = User::factory()->withPassword($this->faker->password(6, 12))->create();
    $password = $this->faker->password(6, 12);
    $user_data = [
      'email' => $this->faker->safeEmail(),
      'fk_group_id' => Group::factory()->has(Modules::factory(), 'modules')->create()->id,
      'name' => $this->faker->name(),
      'password' => $password,
      'password_confirmation' => $password,
    ];

    $response = $this->patch(route('cms.users.update', ['user' => $user->id]), $user_data);

    $response->assertSessionHas('response', cms_response(trans('cms.users.success_update')));
  }

  /** @test */
  public function if_the_user_isnt_found_an_error_is_returned()
  {
    $this->withoutExceptionHandling()->signIn();

    $user = User::all()->last();
    $password = $this->faker->password(6, 12);
    $user_data = [
      'email' => $this->faker->safeEmail(),
      'fk_group_id' => Group::factory()->has(Modules::factory(), 'modules')->create()->id,
      'name' => $this->faker->name(),
      'password' => $password,
      'password_confirmation' => $password,
    ];

    $response = $this->patch(route('cms.users.update', ['user' => $user->id + 1]), $user_data);

    $response->assertSessionHas('response', cms_response(trans('cms.users.error_user_not_found'), false, 400));
  }

  /** @test */
  public function a_user_can_be_updated_without_updating_its_password()
  {
    $this->withoutExceptionHandling()->signIn();

    $user = User::factory()->withPassword($this->faker->password(6, 12))->create();
    $user_data = [
      'email' => $this->faker->safeEmail(),
      'fk_group_id' => Group::factory()->has(Modules::factory(), 'modules')->create()->id,
      'name' => $this->faker->name(),
    ];

    $response = $this->patch(route('cms.users.update', ['user' => $user->id]), $user_data);

    $response->assertSessionHas('response', cms_response(trans('cms.users.success_update')));
  }

  /** @test */
  public function if_passwords_dont_match_when_updating_a_user_an_error_is_returned()
  {
    $this->signIn();

    $user = User::factory()->withPassword($this->faker->password(6, 12))->create();
    $user_data = [
      'email' => $this->faker->safeEmail(),
      'fk_group_id' => Group::factory()->has(Modules::factory(), 'modules')->create()->id,
      'name' => $this->faker->name(),
      'password' => $this->faker->password(6, 12),
      'password_confirmation' => $this->faker->password(6, 12),
    ];

    $this->patch(route('cms.users.update', ['user' => $user->id]), $user_data);

    $this->checkIfSessionErrorMatchesString('password', 'A senha e confirmação de senha não são iguais.');
    $this->checkIfSessionErrorMatchesString('password_confirmation', 'A senha e confirmação de senha não são iguais.');
  }

  /** @test */
  public function users_can_be_excluded()
  {
    $this->withoutExceptionHandling()->signIn();

    $users_id = User::factory(2)->withPassword($this->faker->password(6, 12))->create()->pluck('id');

    $response = $this->delete(route('cms.users.destroy', $users_id));

    $response->assertSessionHas('response', cms_response(trans('cms.users.success_delete')));
  }
}

Controller:

<?php

namespace App\Http\Controllers\Cms;

use App\Http\Controllers\Controller;
use App\Http\Requests\UserRequest;
use App\Services\CmsUsersService;
use Illuminate\Http\Request;

class UsersController extends Controller
{
  private $service;

  public function __construct(CmsUsersService $service)
  {
    $this->service = $service;
  }

  public function store(UserRequest $request)
  {
    $result = $this->service->create($request->all());
    return redirect()->back()->with('response', $result);
  }

  public function update(UserRequest $request, $id)
  {
    $result = $this->service->update($id, $request->all());
    return redirect()->back()->with('response', $result);
  }

  public function destroy($users_id)
  {
    $result = $this->service->delete($users_id);
    return redirect()->back()->with('response', $result);
  }
}

Service:

<?php

namespace App\Services;

use App\Models\User;
use Exception;
use Illuminate\Support\Facades\Hash;
use App\Interfaces\CRUD;

class CmsUsersService implements CRUD
{
  public function create(array $data)
  {
    $data['token'] = Hash::make($data['email']);
    $data['password'] = Hash::make($data['password']);
    User::create($data);

    return cms_response(trans('cms.users.success_create'));
  }

  public function update(int $id, array $data)
  {
    try {
      if (array_key_exists('password', $data)) {
        $data['password'] = Hash::make($data['password']);
      }

      $user = $this->__findOrFail($id);
      $user->update($data);

      return cms_response(trans('cms.users.success_update'));
    } catch (\Throwable $th) {
      return cms_response($th->getMessage(), false, 400);
    }
  }

  public function delete(string $ids)
  {
    User::whereIn('id', json_decode($ids))->delete();
    return cms_response(trans('cms.users.success_delete'));
  }

  private function __findOrFail(int $id)
  {
    $user = User::find($id);
    if ($user instanceof User) {
      return $user;
    }
    throw new Exception(trans('cms.users.error_user_not_found'));
  }
}

0 likes
4 replies
tykus's avatar
tykus
Best Answer
Level 104

In my experience, I have never achieved this test pyramid in a Laravel application; and it has not affected the confidence I have in my applications. I haven't though deeply about the reasons why, in the main because of the confidence in my application that is derived from the feature tests (and few unit tests). However, I think it is difficult to achieve a high number of Unit Tests when working with a framework; many of the units we are testing are not isolated and the framework components they depend on already are unit tested (but are not included as part of our test suites). So the backbone of your application - the framework - is unit tested, it is simply not reflected in the tests we write.

I don't know if this is a wholly valid take, but I can sleep well at night knowing that the application works as expected, and exceptional behaviour is handled appropriately. Ultimately, what do you want from your tests; do you want test purity - the test pyramid and 100% coverage etc. etc. - or, do you want confidence that your application works?

3 likes
MrMoto9000's avatar

@tykus I think you're absolutely correct. If you're using a framework then the pyramid gets inverted. The framework itself is your unit test.

1 like
adityakunhare's avatar

As much I know, Unit test is always close to the models , like CRUD. On the other hand, feature test is always includes a combination of multiple scenarios which includes routes (request) etc.

Thats how I keep the separation of feature and unit tests.

1 like

Please or to participate in this conversation.