테스트란?
테스트의 정의
- 개발자가 작성한 코드를 실행하고 검증하여 코드의 동작과 기능을 확인하는 과정입니다.
- 코드의 오류나 버그를 찾아내고 수정할 수 있도록 도와주며, 안정적이고 정확한 소프트웨어를 개발하게 도움을 줍니다.
- 개발 과정에서 개발자가 직접 포스트맨으로 API를 호출해보고, 버튼을 클릭 하며 콘솔로 확인해보는 것도 테스트의 일종입니다.
(자동화) 테스트의 필요성
- 시간적 효율성
- 수동으로 테스트를 진행한다면, 매 수정마다 혹시모를 에러를 위해 모든 기능을 확인해야 합니다.
- 테스트 당 1분만 걸린다 해도 100개의 테스트를 가진 프로텍트를 3번만 배포해도 6시간(100 x 3 = 300분)이라는 시간이 단순 테스트를 위해서만 소요됩니다.
- 더군다나, 만약 일부가 누락된다면..?
- 심리적 안전망
- 실제로도 개발자가 예기치 못한 오류를 막는 데에 도움을 줍니다.
- 나아가, 개발자에게 '내 실수를 막아줄 무언가가 있다'라는 사실 자체가 리팩토링을 위한 심리적 부담감을 줄여줍니다.
- 문서화
- 인수인계 시, 프로그램이 달성해야 하는 조건과 실제 사례를 테스트코드가 제공합니다.
- 또한 이러한 내용을 개발자가 스스로 정리하면서 놓친 부분이 없는지 다시 한 번 검토하게 됩니다.
테스트의 종류
- 주체에 따른 구분
- 수동 테스트
- 수동으로 테스트 케이스를 실행하는 방식
- 테스터의 노력과 경험에 따라 결과가 크게 달라질 수 있음
- 테스트 케이스를 작성하고 실행하는 데 시간과 비용이 많이 듦
- 기능의 미비점을 발견하는 데는 효과적일 수 있음
- 자동화 테스트
- 자동화된 테스트 도구를 사용하여 테스트 케이스를 자동으로 실행하는 방식
- 높은 정확성과 일관성을 가짐
- 테스트 케이스를 작성하고 실행하는 데 시간과 비용이 적게 듦
- 반복적인 작업을 자동화하여 개발자가 더욱 효과적으로 작업할 수 있도록 함
- 대규모 또는 복잡한 시스템에서는 필수적일 수 있음
- 수동 테스트
- 수준에 따른 구분
- E2E 테스트
- 유저의 입장에서 프론트, 백엔드를 포함한 양 끝단을 점검하는 테스트
- 테스트의 비용이 비쌈(소요시간, 리소스)
- 리팩토링 내성이 높음
- 통합 테스트
- 단위 테스트에서 검증한 모듈을 조합하여, 시스템 전체의 동작을 검증하는 방법
- 각 모듈의 상호작용을 검증하며, 모듈 간의 연계성을 확인할 수 있음
- E2E 테스트는 통합 테스트에 포함될 수 있음
- 유닛 테스트
- 단일 모듈, 함수, 클래스 등의 최소 단위를 테스트하는 방법
- 모듈 내부 로직을 검증하며, 모듈 간의 의존성을 줄이고 모듈의 독립성을 유지할 수 있음
- 테스트의 비용이 매우 저렴함(수 초 이내)
- 리팩토링 내성이 낮음
- E2E 테스트
- 기타 구분
- 기능 테스트(Feature Test)
- 사용자가 원하는 기능이 요구사항에 맞게 동작하는지 검증하는 방법
- API 테스트를 포함함. 백엔드 개발자가 직접 포스트맨 등의 툴로 Request를 쏘는 것도 이에 해당함.
- 기능 테스트(Feature Test)
자동화 테스트의 구성
- 일반적인 구성
- (주관적) 효율적인 구성
테스트코드의 일반적인 구조
- AAA 패턴: Arrange / Act / Assert
- GWT 패턴: Given / When / Then
⇒ 약간의 차이가 있지만, 준비-실행-확인이라는 차원에서 사실상 동일하다고 생각하시면 됩니다.
테스트에서의 주요 개념
- 모킹(Mocking): 코드에서 사용되는 외부 의존성을 대신하는 객체. 실제 코드를 호출할때 발생하는 비용을 줄이기 위해 대역을 사용하는 것
- 스파이(Spying): 모킹의 일종으로, 호출 결과를 확인하기 위해 기록하거나 저장하는 등의 부가기능을 가진 모킹 객체
- 픽스쳐(Fixture): 모든 테스트에서 공통으로 사용하기위해 준비하는 초기 데이터나 객체
- 셋업(Setup): 테스트를 수행하기 전에, 테스트에 필요한 초기 데이터나 객체를 설정하는 작업. 단일 테스트를 수행하기 위해 초기 데이터나 객체를 미리 생성하는 단계. 셋업은 매 테스트마다 한 번씩 수행됨
- 티어다운(Teardown): 테스트가 완료된 후에, 다음 테스트에서 동일한 조건을 준비하기 위해 이전 테스트에서 생성한 데이터나 객체를 삭제하거나, 기타 정리 작업을 수행하는 작업. 티어다운은 매 테스트 종료 후 한 번씩 수행됨
테스트 준비
DB 준비하기
phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/> <----- Here
<env name="DB_DATABASE" value=":memory:"/> <----- Here
<env name="API_DEBUG" value="false"/>
<env name="memory_limit" value="512M"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>
저는 보통 간단한 테스트 환경을 위해 sqlite 메모리에 띄워서 테스트를 진행합니다. 물론 실제 DB와 다른 점이 있어서, 특정 상황에서 추가적인 세팅이 필요하긴 하지만, 속도도 빠르고 대체로 비슷하게 작동하기에 이렇게 사용합니다.
위의 설정 외에도, config/database.php 내에 별도 connection을 설정한 뒤 사용 할 수 있습니다.
'sqlite_testing' => [
'driver' => 'sqlite',
'database' => database_path('testing.sqlite'),
'prefix' => '',
],
이후에는 phpunit.xml에 아래와 같이 수정해준 뒤,
...
<env name="DB_CONNECTION" value="sqlite_testing"/>
...
아래 명령어로 migration을 해줍니다.
php artisan migrate --database=sqlite_testing
다른 DB를 사용하는 경우도 마찬가지로 준비해주면 됩니다.
TestCase 준비하기
<?php
namespace Tests;
use Illuminate\\Foundation\\Testing\\TestCase as BaseTestCase;
use Illuminate\\Support\\Facades\\Artisan;
use App\\Models\\User;
use App\\Models\\Post;
use App\\Models\\Comment;
use Faker\\Factory as FakerFactory;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
protected $faker;
protected function setUp(): void
{
parent::setUp();
// database migrations, Passport installation, and database seeders를 실행합니다.
Artisan::call('migrate', ['-vvv' => true]);
Artisan::call('passport:install', ['-vvv' => true]);
Artisan::call('db:seed', ['-vvv' => true]);
// Faker 인스턴스를 생성합니다.
$this->faker = FakerFactory::create();
// 테스트를 위한 users, posts, and comments 데이터를 생성합니다
$this->users = factory(User::class, 5)->create();
foreach ($this->users as $user) {
$post = factory(Post::class)->create([
'user_id' => $user->id,
'title' => $this->faker->sentence(),
'body' => $this->faker->paragraph(),
]);
$comment = factory(Comment::class)->create([
'user_id' => $user->id,
'post_id' => $post->id,
'body' => $this->faker->sentence(),
]);
}
}
protected function tearDown(): void
{
// 테스트 후 users, posts, and comments를 삭제해줍니다.
foreach ($this->users as $user) {
foreach ($user->posts as $post) {
$post->comments()->delete();
$post->delete();
}
$user->delete();
}
// DB를 롤백해줍니다.
Artisan::call('migrate:rollback', ['-vvv' => true]);
parent::tearDown();
}
}
위에서 말씀드린 셋업(setUp)과 티어다운(tearDown)이 등장했습니다.
예제에서는 DB를 준비하고 faker를 활용해서 user, post, comment를 미리 생성하는 등의 작업을 수행하고, 테스트 간 발생한 변동이 다음 테스트에 영향을 주지 않도록 tearDown까지 진행하고있습니다.
기능 테스트
저는 FeatureTest를 ApiTest로 사용합니다.
Feature/ExampleTest.php
<?php
namespace Tests\\Feature;
// use Illuminate\\Foundation\\Testing\\RefreshDatabase;
use Tests\\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function test_the_application_returns_a_successful_response()
{
$response = $this->get('/');
$response->assertStatus(404);
}
}
라라벨 프로젝트 최초 생성 시 주어지는 Feature Test의 예제 코드입니다.
위의 코드는 get request이다보니 Arrange(Given) 이 존재하지 않습니다.
아래의 코드를 통해 전체적인 모습을 확인하실 수 있습니다.
public function test_can_create_new_user()
{
// Arrange / Given
$data = [
'name' => 'John Doe',
'email' => 'johndoe@example.com',
'password' => 'secret',
];
// Act / When
$response = $this->json('POST', '/api/users', $data);
// Assert / Then
$response->assertStatus(201)
->assertJson([
'created' => true,
]);
}
유닛 테스트
Unit/ExampleTest.php
<?php
namespace Tests\\Unit;
use PHPUnit\\Framework\\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function test_example()
{
$this->assertTrue(true);
}
}
public function test_store_method_saves_new_user_to_database()
{
// user 객체 생성
$user = new User([
'name' => 'John Doe',
'email' => 'johndoe@example.com',
'password' => 'secret',
]);
// request 객체를 모킹합니다.
$request = new Request([
'name' => 'John Doe',
'email' => 'johndoe@example.com',
'password' => 'secret',
]);
// 테스트를 위해 UsersController 객체를 만들어줍니다.
$controller = new UsersController();
// 모킹한 request 객체를 활용해 usersController의 store 함수를 호출합니다.
$response = $controller->store($request);
// user가 DB에 성공적으로 저장되었는지 점검합니다.
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
'email' => 'johndoe@example.com',
]);
}
부록
권한 검증을 위한 테스트 코드
// in TestCase.php
protected function assertAuthenticationRequired($uri, $method = 'get', $redirect = '/404')
{
$method = strtolower($method);
if (!in_array($method, ['get', 'post', 'put', 'update', 'delete', 'patch'])) {
throw new InvalidArgumentException('Invalid method: '.$method);
}
// Html check
$response = $this->$method($uri);
if($response->getStatusCode() == 404) {
$response->assertStatus(404);
$response->assertJsonStructure(['statusCode', 'message']);
} else {
$response->assertStatus(302);
}
// Json check
$method .= 'Json';
$response = $this->$method($uri);
if($response->getStatusCode() == 404) {
$response->assertStatus(404);
$response->assertJsonStructure(['statusCode', 'message']);
} else {
$response->assertStatus(401);
}
$response->assertJsonStructure(['message']);
}
예전에 구글링하던 중 발췌한 코드로 기억하는데, 출처를 찾지 못해 기록하진 못했습니다.
아래와 같이 사용하며, 해당 엔드포인트가 특정 요청에 대해 로그인 등 권한을 필요로 하는지를 빠르게 점검해줍니다.
ApiHttpTest.php
<?php
namespace Tests\\Feature;
use Tests\\TestCase;
class ApiHttpTest extends TestCase
{
/**
* Board
*/
public function testAuthenticationBoard()
{
$this->assertAuthenticationRequired('/api/v1/boards/', 'GET');
$this->assertAuthenticationRequired('/api/v1/boards/{boardId}', 'GET');
$this->assertAuthenticationRequired('/api/v1/boards/', 'POST');
$this->assertAuthenticationRequired('/api/v1/boards/{boardId}', 'PUT');
$this->assertAuthenticationRequired('/api/v1/boards/{boardId}', 'DELETE');
}
// ...
}
테스트 디렉토리 구조(Scaffolding)
일반적으로 app/Http/Controller 에 대한 테스트는 tests/Http/Controller 디렉토리에 대응시키는 방식을 사용합니다.
app/
./app
...
├── Http
│ ├── Controllers
│ │ ├── Admin
│ │ │ ├── AdminAuthController.php
│ │ │ ├── ...
│ │ ├── AuthController.php
│ │ │ ├── ...
└── Traits
...
tests/
./tests
├── Feature
│ └── Http
│ ├── ApiHttpTest.php
│ ├── Controllers
│ │ ├── Admin
│ │ │ ├── AdminAuthController.php
│ │ │ ├── ...
│ │ ├── AuthControllerTest.php
│ │ ├── ...
├── TestCase.php
└── Unit
├── ...
꼭 위와 같은 구조로 테스트를 구성할 필요는 없지만, 동일한 구조로 배치를 하는 것이 후에 수정하거나 누락된 테스트를 찾는 데에 도움이 됩니다.
참고자료
https://testerstories.com/2020/09/test-shapes/