1. 程式人生 > >API 系列教程(一):基於 Laravel 5.5 構建 和 測試 RESTful API

API 系列教程(一):基於 Laravel 5.5 構建 和 測試 RESTful API

隨著移動開發和 JavaScript 框架的日益流行,使用 RESTful API 在資料層和客戶端之間構建互動介面逐漸成為最佳選擇。

在本系列教程中,將會帶領大家基於 Laravel 5.5 來構建並測試帶認證功能的 RESTful API。

RESTful API

先要了解什麼是 RESTful API。REST 是 Representational State Transfer 的縮寫,表示一種應用之間網路通訊的架構風格,依賴於無狀態的協議(通常是HTTP)進行互動。

通過 HTTP 動詞表示操作

在 RESTful API 中,我們使用 HTTP 動詞表示操作,而端點是操作的資源,HTTP 動詞的語義如下:

  • GET:獲取資源
  • POST:建立資源
  • PUT:更新資源
  • DELETE:刪除資源

更新操作:PUT vs POST

關於 RESTful API 有一些爭議,比如更新資源使用 POST、PATCH 還是 PUT 哪一個更好?

在本教程中,我們使用 PUT 進行更新操作,因為基於 HTTP RFC 標準,PUT 的含義是在指定位置上建立/更新資源;使用 PUT 的另一個原因是冪等性,這意味著不管你傳送一次、兩次還是上千次請求,操作結果都一致。

資源

在 RESTful API 中,資源指的是操作的物件,在我們的例子中就是文章(Articles)和使用者(Users)。

它們各自的端點是:

  • /articles
  • /users

在我們的教程中,資源和資料模型一一對應,但這並不是強制性的要求。

關於一致性的注意項

使用 REST 的最大好處是更容易消費和開發 API,一些端點非常直截了當,這樣相較於類似 GET /get_article?id_article=12 這樣的端點 RESTful API 更容易使用和維護。

不過,在某些案例中對映到 Create/Retrieve/Update/Delete 可能會很困難。

需要牢記的是: URL 中不要包含任何動詞而且資源並不一定非得是資料表的某一行資料。另一個需要記住的是不必為每個資源實現所有操作。

構建一個新的 Laravel 專案

建立新應用

首先,通過 Composer 來安裝 Laravel 5.5。

composer create-project --prefer-dist laravel/laravel apidemo 5.5.*

然後,自行配置 web 伺服器,並修改 hosts 檔案。

127.0.0.1 apidemo.test

檢測是否可以正常訪問 http://apidemo.test 。

修改 config/app.php 中的時區 timezone 配置:

'timezone' => 'Asia/Shanghai',

建立遷移和模型

在編寫第一個遷移之前,需要將 .env 檔案中的環境變數調整為開發環境中的資料庫配置。

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=apidemo
DB_USERNAME=root
DB_PASSWORD=root

接下來,就可以開始建立我們的第一個 Article 模型及其對應的遷移檔案。

在專案根目錄,執行如下 Artisan 命令一步到位:

php artisan make:model Article -m

-m 是 --migration 的縮寫,告知 Artisan 在建立模型的同時建立與之對應的遷移檔案。

上述命令建立的模型檔案是 app/Article.php,遷移檔案是 database/migrations/2018_04_03_160023_create_articles_table.php。

當然,還需要編輯該遷移檔案的內容:

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateArticlesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->increments('id');
            $table->string('title', 100);
            $table->text('body');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('articles');
    }
}

然後,執行命令 php artisan migrate ,就會根據遷移檔案來建立對應的資料表結構了。

php artisan migrate

執行成功後,就可以看到 apidemo 資料庫中自動生成了 4 張表,articles、migrations、password_resets 和 users。

當然,只有 articles 表是由我們手動生成的遷移檔案來生成的。

由上述遷移檔案生成的 articles 表的結構為:

CREATE TABLE `articles` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `title` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
  `body` text COLLATE utf8mb4_unicode_ci NOT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

修改模型

修改 Article 模型類,新增如下屬性欄位到 $fillable ,以便可以在 Article::create 和 Article::update 方法中使用它們。

class Article extends Model
{
    protected $fillable = ['title', 'body'];
}

資料庫填充

Laravel 通過 Faker 庫可以快速為我們生成格式正確的測試資料。

建立填充器類:

php artisan make:seeder ArticlesTableSeeder

填充器類預設會存放在 database/seeds 目錄下。

編輯 database/seeds/ArticlesTableSeeder.php 填充器:

<?php

use Illuminate\Database\Seeder;
use App\Article;

class ArticlesTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        // Let's truncate our existing records to start from scratch.
        Article::truncate();

        $faker = \Faker\Factory::create();

        // And now, let's create a few articles in our database:
        for ($i = 0; $i < 50; $i++) {
            Article::create([
                'title' => $faker->sentence,
                'body' => $faker->paragraph,
            ]);
        }
    }
}

然後執行填充命令:

php artisan db:seed --class=ArticlesTableSeeder

類似地,再建立使用者表填充器類:

php artisan make:seeder UsersTableSeeder

修改 database/seeds/UsersTableSeeder.php 檔案:

<?php

use Illuminate\Database\Seeder;
use App\User;

class UsersTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        // Let's clear the users table first
        User::truncate();

        $faker = \Faker\Factory::create();

        // Let's make sure everyone has the same password and
        // let's hash it before the loop, or else our seeder
        // will be too slow.
        $password = Hash::make('toptal');

        User::create([
            'name' => 'Administrator',
            'email' => '[email protected]',
            'password' => $password,
        ]);

        // And now let's generate a few dozen users for our app:
        for ($i = 0; $i < 10; $i++) {
            User::create([
                'name' => $faker->name,
                'email' => $faker->email,
                'password' => $password,
            ]);
        }
    }
}

然後,修改 database/seeds/DatabaseSeeder.php 檔案:

<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        $this->call(UsersTableSeeder::class);
        $this->call(ArticlesTableSeeder::class);
    }
}

這樣的話,只需執行下面的命令:

php artisan db:seed

就可以呼叫多個填充器來填充資料。

路由和控制器

註冊路由

有了資料之後,接下來我們來為應用建立基本介面:建立、獲取列表、獲取單條記錄、更新以及刪除。

在 routes/api.php (API 介面路由檔案)中,新增下面的路由配置:

<?php

use Illuminate\Http\Request;
use App\Article;

Route::get('articles', function() {
    // If the Content-Type and Accept headers are set to 'application/json',
    // this will return a JSON structure. This will be cleaned up later.
    return Article::all();
});

Route::get('articles/{id}', function($id) {
    return Article::find($id);
});

Route::post('articles', function(Request $request) {
    return Article::create($request->all);
});

Route::put('articles/{id}', function(Request $request, $id) {
    $article = Article::findOrFail($id);
    $article->update($request->all());

    return $article;
});

Route::delete('articles/{id}', function($id) {
    Article::find($id)->delete();

    return 204;
});

對於 routes/api.php 中定義的路由,訪問時需要加上 /api/ 字首,並且 API 限流中介軟體會自動應用到所有路由。

配置好上述路由之後,就可以訪問某些 API 介面了。

比如,獲取文章的單條記錄 API 介面:

GET /api/articles/{id}

在 Postman 客戶端中,測試該 API 介面。

GET http://apidemo.test/api/articles/1

建立控制器

接下來,我們來建立控制器,以便把路由閉包中的業務邏輯調整到控制器中。

// 建立文章控制器
php artisan make:controller ArticleController

編輯 app/Http/Controllers/ArticleController.php :

<?php

namespace App\Http\Controllers;

use App\Article;
use Illuminate\Http\Request;

class ArticleController extends Controller
{
    /**
     *  獲取文章列表
     */
    public function index()
    {
        return Article::all();
    }

    /**
     *  獲取文章的單條記錄
     */
    public function show($id)
    {
        return Article::find($id);
    }

    /**
     *  建立新文章
     */
    public function store(Request $request)
    {
        return Article::create($request->all());
    }

    /**
     *  更新文章
     */
    public function update(Request $request, $id)
    {
        $article = Article::findOrFail($id);
        $article->update($request->all());

        return $article;
    }

    /**
     *  刪除文章
     */
    public function delete(Request $request, $id)
    {
        $article = Article::findOrFail($id);
        $article->delete();

        return 204;
    }
}

然後,調整 routes/api.php 檔案中的 articles 相關路由即可。

Route::get('articles', '[email protected]');
Route::get('articles/{id}', '[email protected]');
Route::post('articles', '[email protected]');
Route::put('articles/{id}', '[email protected]');
Route::delete('articles/{id}', '[email protected]');

隱式的路由模型繫結

還可以通過隱式路由模型繫結,來改寫路由。

Route::get('articles', '[email protected]');
Route::get('articles/{article}', '[email protected]');
Route::post('articles', '[email protected]');
Route::put('articles/{article}', '[email protected]');
Route::delete('articles/{article}', '[email protected]');

當然,相應的,也要調整控制器程式碼:

class ArticleController extends Controller
{
    /**
     *  獲取文章列表
     */
    public function index()
    {
        return Article::all();
    }

    /**
     *  獲取文章的單條記錄
     */
    public function show(Article $article)
    {
        return $article;
    }

    /**
     *  建立新文章
     */
    public function store(Request $request)
    {
        $article = Article::create($request->all());

        return response()->json($article, 201);
    }

    /**
     *  更新文章
     */
    public function update(Request $request, Article $article)
    {
        $article->update($request->all());

        return response()->json($article, 200);
    }

    /**
     *  刪除文章
     */
    public function delete(Article $article)
    {
        $article->delete();

        return response()->json(null, 204);
    }
}

改用隱式路由模型繫結後,API 介面的返回結果與之前是一致的。

關於 HTTP 狀態碼的注意項

我們使用了 response()->json(),這可以讓我們在顯示返回 JSON 資料的同時傳送可以被客戶端解析的 HTTP 狀態碼。

常用的 HTTP 狀態碼如下:

  • 200:OK,標準的響應成功狀態碼
  • 201:Object created,用於 store 操作
  • 204:No content,操作執行成功,但是沒有返回任何內容
  • 206:Partial content,返回部分資源時使用
  • 400:Bad request,請求驗證失敗
  • 401:Unauthorized,使用者需要認證
  • 403:Forbidden,使用者認證通過但是沒有許可權執行該操作
  • 404:Not found,請求資源不存在
  • 500:Internal server error,通常我們並不會顯示返回這個狀態碼,除非程式異常中斷
  • 503:Service unavailable,一般也不會顯示返回該狀態碼,通常用於排查問題

傳送 404 響應

如果你試圖獲取不存在的資源,會返回 404 頁面。

比如:http://apidemo.test/api/articles/1000

對於 404 響應, Laravel 預設提供的是一個 html 頁面響應。

如果想要將其改為返回 JSON 響應,可以修改異常處理器 app/Exceptions/Handler.php 的 render 方法。

<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class Handler extends ExceptionHandler
{

    /**
     * Render an exception into an HTTP response.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Exception  $exception
     * @return \Illuminate\Http\Response
     */
    public function render($request, Exception $exception)
    {
        if ($exception instanceof ModelNotFoundException) {
            return response()->json([
                'error' => 'Resource not found.'
            ],404);
        }
        return parent::render($request, $exception);
    }
}

再來訪問不存在的資源,返回的結果如下:

{
    "error": "Resource not found."
}

API 認證

在 Laravel 中實現 API 認證有多種方式(例如 Passport),這裡我們使用一個非常簡化的方式。

新增 api_token 欄位

首先,需要新增 api_token 欄位到 users 表。

我們通過建立新的遷移來修改 users 的表結構。

php artisan make:migration --table=users adds_api_token_to_users_table

編輯該遷移檔案:

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddsApiTokenToUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('api_token', 60)->unique()->nullable();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['api_token']);
        });
    }
}

執行遷移命令,以便作用於資料表。

php artisan migrate

這樣,users 表中就自動新增了 api_token 欄位。

建立註冊介面

我們使用 RegisterController 來根據註冊請求返回正確的響應。儘管 Laravel 開箱提供了認證功能,但是我們還是需要對其進行調整以便返回我們想要的響應資料。

只需在 app/Http/Controllers/Auth/RegisterController.php 中實現 registered 方法即可。

protected function registered(Request $request, $user)
{
    $user->generateToken();

    return response()->json(['data' => $user->toArray()], 201);
}

在上面的示例程式碼中,我們呼叫了 User 模型上的生成令牌方法 generateToken(),但該方法還不存在。故我們需要修改 User 模型類(app/User.php),新增該方法。

public function generateToken()
{
   $this->api_token = str_random(60);
   $this->save();

   return $this->api_token;
}

然後,在 routes/api.php 中,新增使用者註冊介面的路由:

Route::post('register', 'Auth\[email protected]');

至此,註冊介面編寫完成,使用者現在可以通過註冊介面進行註冊了,感謝 Laravel 開箱提供的認證欄位驗證功能,如果你需要調整驗證規則的話可以到 RegisterController 中檢視 validator 方法。

接下來,下面我們來簡單測試下使用者註冊介面:

curl -X POST http://apidemo.test/api/register \
    -H "Accept: application/json" \
    -H "Content-Type: application/json" \
    -d '{"name": "user01", "email": "[email protected]", "password": "test123", "password_confirmation": "test123"}'

使用者註冊成功後的響應結果:

{
    "data": {
        "name": "user01",
        "email": "[email protected]",
        "updated_at": "2018-04-04 22:05:43",
        "created_at": "2018-04-04 22:05:43",
        "id": 14,
        "api_token": "Lqd7HfpFaptghJxyV7VaQwUv5JYqIUTehzOblvDDZxIx0M4PpIfODNcKNSVK"
    }
}

建立登入介面

和使用者註冊介面類似,可以編輯 LoginController 控制器來支援 API 認證。

為此,我們需要在 LoginController 覆蓋 AuthenticatesUsers trait 提供的 login 方法。

修改 app/Http/Controllers/Auth/LoginController.php 檔案。

先新增

use Illuminate\Http\Request;

然後,新增

public function login(Request $request)
{
    $this->validateLogin($request);

    if ($this->attemptLogin($request)) {
        $user = $this->guard()->user();
        $user->generateToken();

        return response()->json([
            'data' => $user->toArray(),
        ]);
    }

    return $this->sendFailedLoginResponse($request);
}

然後在 routes/api.php 中,配置使用者登入的路由:

Route::post('login', 'Auth\[email protected]');

現在,基於我們上面註冊的新使用者,我們來測試下登入介面:

curl -X POST http://apidemo.test/api/login \
-H "Content-type: application/json" \
-d '{"email": "[email protected]", "password": "test123"}'

登入成功返回結果:

{
    "data": {
        "id": 14,
        "name": "user01",
        "email": "[email protected]",
        "created_at": "2018-04-04 22:05:43",
        "updated_at": "2018-04-04 22:53:13",
        "api_token": "1QzaV8ebVUtrgF6qcvsmdL7S0Jh09tqR4tB1SBQ5tUacl0w2YWjzyLubXTgy"
    }
}

後面,可以拿著這個 api_token 作為令牌來請求需要認證的資源了。使用我們現有的策略,請求認證資源時,如果沒有 token 或 token 錯誤,使用者將會接收到未認證響應(401)。

建立退出介面

為了形成完整閉環,下面我們來編寫退出登入介面,實現思路是使用者發起退出登入請求時,我們將其對應的 api_token 欄位值從資料庫移除。

在 routes/api.php 中,新增路由:

Route::post('logout', 'Auth\[email protected]');

然後在 Auth\LoginController.php 中編寫 logout 方法:

public function logout(Request $request)
{
    $user = Auth::guard('api')->user();

    if ($user) {
        $user->api_token = null;
        $user->save();
    }

    return response()->json(['data' => 'User logged out.'], 200);
}

使用該策略,一旦退出,使用者的所有令牌都會失效,訪問需要認證的 API 介面都會拒絕訪問(通過中介軟體實現)。

這需要和前端配合來避免使用者在沒有訪問任何內容的許可權下保持登入狀態。

請求頭

通過 token 來保持使用者的登入狀態。因此,使用者登入成功之後,請求後續的 API 介面時,需要傳遞下面的請求頭資訊。

 $headers = ['Authorization' => "Bearer $token"];

Auth::guard('api') 會根據這個請求頭,來判斷當前的使用者資訊,並判斷使用者的登入狀態。

注:$token 的值就是 api_token 欄位的值,使用者每次登入成功後,api_token 都會重新整理。

使用中介軟體限制訪問

api_token 建立之後,我們就可以在路由檔案中應用認證中介軟體了:

Route::middleware('auth:api')
    ->get('/user', function (Request $request) {
        return $request->user();
    });

我們可以使用 $request->user() 或 Auth 門面訪問當前使用者:

Auth::guard('api')->user(); // 登入使用者例項
Auth::guard('api')->check(); // 使用者是否登入
Auth::guard('api')->id(); // 登入使用者ID    

接下來,我們將之前定義的文章相關路由進行分組:

Route::group(['middleware' => 'auth:api'], function() {
    Route::get('articles', '[email protected]');
    Route::get('articles/{article}', '[email protected]');
    Route::post('articles', '[email protected]');
    Route::put('articles/{article}', '[email protected]');
    Route::delete('articles/{article}', '[email protected]');
});

這樣就不需要為每個路由單獨設定中介軟體,好處是保持路由的DRY(Don’t Repeat Yourself)。

注:並不是所有的 API 介面都需要通過 auth 中介軟體認證過濾。

請求需要認證的資源時,如果請求頭中沒有 token 或 token 錯誤,就是認證失敗,會丟擲 AuthenticationException 異常。

AuthenticationException 異常是由 /Illuminate/Foundation/Exceptions/Handler.php 異常處理器的 unauthenticated() 方法來處理的。

protected function unauthenticated($request, AuthenticationException $exception)
{
    return $request->expectsJson()
                ? response()->json(['message' => $exception->getMessage()], 401)
                : redirect()->guest(route('login'));
}

如果想自定義認證失敗後的響應,可以在 app/Exceptions/Handler.php 中重寫該方法。

先新增

use Illuminate\Auth\AuthenticationException;

然後,重寫 unauthenticated() 方法:

/**
 * Convert an authentication exception into a response.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Illuminate\Auth\AuthenticationException  $exception
 * @return \Illuminate\Http\Response
 */
protected function unauthenticated($request, AuthenticationException $exception)
{
    return response()->json(['message' => $exception->getMessage()], 401);
}

這樣的話,只要認證失敗,就一定會返回自定義的 JSON 響應。

例如,直接訪問 http://apidemo.test/api/articles/1 ,而沒有傳遞帶 token 的請求頭資訊的話,就會得到未認證響應(401)。

{
    "message": "Unauthenticated."
}

API 介面總結

現在,我們已經構建好了 RESTful 風格的 API 介面。

routes/api.php 檔案中的 API 路由配置如下:

<?php

use Illuminate\Http\Request;

Route::post('register', 'Auth\[email protected]');
Route::post('login', 'Auth\[email protected]');
Route::post('logout', 'Auth\[email protected]');

Route::group(['middleware' => 'auth:api'], function() {
    Route::get('articles', '[email protected]');
    Route::get('articles/{article}', '[email protected]');
    Route::post('articles', '[email protected]');
    Route::put('articles/{article}', '[email protected]');
    Route::delete('articles/{article}', '[email protected]');
});

API 介面列表:

API 名稱 請求方式 功能描述
/api/register POST 使用者註冊
/api/login POST 使用者登入
/api/logout POST 退出登入
/api/articles GET 獲取文章列表
/api/articles/{article} GET 獲取指定的一篇文章
/api/articles POST 新增新文章
/api/articles/{article} PUT 更新文章
/api/articles/{article} DELETE 刪除文章

測試介面

初始化設定

Laravel 開箱集成了 PHPUnit 進行測試,並且在專案根目錄下為我們配置好了 phpunit.xml。

本教程中,我們使用內建的測試方法來測試上面編寫的 API。

開始之前,我們需要做一些小調整以便使用記憶體級的 SQLite 資料庫進行資料儲存。

這樣做的好處是可以讓測試更快執行,但缺點是某些遷移命令可能不能正常執行,我的建議是當你遇到執行遷移命令出錯或者更傾向於更加健壯的測試而不是高效能時不要使用 SQLite。

我們還會在每個測試之前執行遷移,這樣就可以為每次測試構建資料庫然後銷燬掉,從而避免不同組測試間的相互干擾。

在 config/database.php 檔案中,設定 sqlite 配置項中的 database 欄位值為 :memory: 。

...
'connections' => [

    'sqlite' => [
        'driver' => 'sqlite',
        'database' => ':memory:',
        'prefix' => '',
    ],

    ...
]

然後在 phpunit.xml 中通過新增 DB_CONNECTION 環境變數來啟用 SQLite:

<php>
    <env name="APP_ENV" value="testing"/>
    <env name="CACHE_DRIVER" value="array"/>
    <env name="SESSION_DRIVER" value="array"/>
    <env name="QUEUE_DRIVER" value="sync"/>
    <env name="DB_CONNECTION" value="sqlite"/>
</php>

基本配置已經完成,接下來就是配置 TestCase 在每次測試前執行遷移並填充資料庫。為此,我們需要新增 DatabaseMigrations trait 然後在 setUp() 方法中新增 Artisan 呼叫。

修改 tests/TestCase.php 檔案:

namespace Tests;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication, DatabaseMigrations;

    public function setUp()
    {
        parent::setUp();
        Artisan::call('db:seed');
    }
}

測試命令

執行 phpunit 測試命令:

# Windows 環境下需要用反斜線
vendor\bin\phpunit

# Linux 環境下用正斜線
vendor/bin/phpunit

如果報錯資訊為:

'vendor' 不是內部或外部命令,也不是可執行的程式
或批處理檔案。

請刪除專案目錄下的 vendor 目錄,然後重新利用 composer 安裝依賴。

composer install

如果報錯資訊為:

Error: Class 'Doctrine\DBAL\Driver\PDOSqlite\Driver' not found

這是因為沒有安裝 doctrine/dbal 擴充套件包,使用 Composer 安裝即可:

composer require doctrine/dbal

測試命令的正常輸出示例:

PHPUnit 6.5.7 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 522 ms, Memory: 14.00MB

OK (2 tests, 2 assertions)

建立模型工廠

模型工廠可以讓我們快速生成測試資料,Laravel 開箱自帶了 User 模型工廠,下面我們為 Article 類添加工廠:

php artisan make:factory ArticleFactory

模型工廠的存放目錄是 database/factories。

編輯 ArticleFactory 類:

<?php

use Faker\Generator as Faker;
use App\Article;

$factory->define(Article::class, function (Faker $faker) {
    return [
        'title' => $faker->sentence,
        'body' => $faker->paragraph,
    ];
});

編寫測試用例

可以使用 Laravel 的斷言方法對請求和響應進行測試。

下面我們來建立第一個測試用例 —— 登入測試

php artisan make:test LoginTest

編輯 tests/Feature/LoginTest.php 檔案:

<?php

namespace Tests\Feature;

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

class LoginTest extends TestCase
{
    public function testRequiresEmailAndLogin()
    {
        $this->json('POST', 'api/login')
             ->assertStatus(422)
             ->assertJson([
                'message' => "The given data was invalid.",
                'errors'  => [
                    'email' => ['The email field is required.'],
                    'password' => ['The password field is required.']
                ]
             ]);
    }

    public function testUserLoginsSuccessfully()
    {
        $user = factory(User::class)->create([
            'email' => '[email protected]',
            'password' => bcrypt('test123'),
        ]);

        $payload = ['email' => '[email protected]', 'password' => 'test123'];

        $this->json('POST', 'api/login', $payload)
            ->assertStatus(200)
            ->assertJsonStructure([
                'data' => [
                    'id',
                    'name',
                    'email',
                    'created_at',
                    'updated_at',
                    'api_token',
                ],
            ]);
    }
}

註冊測試

php artisan make:test RegisterTest

tests/Feature/RegisterTest.php 內容如下:

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class RegisterTest extends TestCase
{
    public function testsRegistersSuccessfully()
    {
        $payload = [
            'name' => 'John',
            'email' => '[email protected]',
            'password' => 'toptal123',
            'password_confirmation' => 'toptal123',
        ];

        $this->json('post', '/api/register', $payload)
            ->assertStatus(201)
            ->assertJsonStructure([
                'data' => [
                    'id',
                    'name',
                    'email',
                    'created_at',
                    'updated_at',
                    'api_token',
                ],
            ]);;
    }

    public function testsRequiresPasswordEmailAndName()
    {
        $this->json('post', '/api/register')
             ->assertStatus(422);
    }

    public function testsRequirePasswordConfirmation()
    {
        $payload = [
            'name' => 'John',
            'email' => '[email protected]',
            'password' => 'toptal123',
        ];

        $this->json('post', '/api/register', $payload)
            ->assertStatus(422)
            ->assertJson([
                'message' => "The given data was invalid.",
                'errors'  => [
                    'password' => ['The password confirmation does not match.'],
                ]
            ]);
    }
}

退出測試

php artisan make:test LogoutTest

編輯 LogoutTest 程式碼如下:

<?php

namespace Tests\Feature;

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

class LogoutTest extends TestCase
{
    public function testUserIsLoggedOutProperly()
    {
        $user = factory(User::class)->create(['email' => '[email protected]']);
        $token = $user->generateToken();
        $headers = ['Authorization' => "Bearer $token"];

        $this->json('get', '/api/articles', [], $headers)->assertStatus(200);
        $this->json('post', '/api/logout', [], $headers)->assertStatus(200);

        $user = User::find($user->id);

        $this->assertEquals(null, $user->api_token);
    }

    public function testUserWithNullToken()
    {
        // Simulating login
        $user = factory(User::class)->create(['email' => '[email protected]']);
        $token = $user->generateToken();
        $headers = ['Authorization' => "Bearer $token"];

        // Simulating logout
        $user->api_token = null;
        $user->save();

        $this->json('get', '/api/articles', [], $headers)->assertStatus(401);
    }
}

注:在測試期間,Laravel 應用並不會在發起新請求時再次初始化,所以會在請求之間儲存當前使用者到 TokenGuard 例項,也因此我們不得不將退出測試一分為二,以避免受之前快取使用者的影響。

文章 API 介面測試:

php artisan make:test ArticleTest

編寫 ArticleTest 程式碼如下:

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\User;
use App\Article;

class ArticleTest extends TestCase
{
    public function testsArticlesAreCreatedCorrectly()
    {
        $user = factory(User::class)->create();
        $token = $user->generateToken();
        $headers = ['Authorization' => "Bearer $token"];
        $payload = [
            'title' => 'Lorem',
            'body' => 'Ipsum',
        ];

        $this->json('POST', '/api/articles', $payload, $headers)
             ->assertStatus(201);
    }

    public function testsArticlesAreUpdatedCorrectly()
    {
        $user = factory(User::class)->create();
        $token = $user->generateToken();
        $headers = ['Authorization' => "Bearer $token"];
        $article = factory(Article::class)->create([
            'title' => 'First Article',
            'body' => 'First Body',
        ]);

        $payload = [
            'title' => 'Lorem',
            'body' => 'Ipsum',
        ];

        $response = $this->json('PUT', '/api/articles/' . $article->id, $payload, $headers)
            ->assertStatus(200)
            ->assertJson([
                'title' => 'Lorem',
                'body' => 'Ipsum'
            ]);
    }

    public function testsArtilcesAreDeletedCorrectly()
    {
        $user = factory(User::class)->create();
        $token = $user->generateToken();
        $headers = ['Authorization' => "Bearer $token"];
        $article = factory(Article::class)->create([
            'title' => 'First Article',
            'body' => 'First Body',
        ]);

        $this->json('DELETE', '/api/articles/' . $article->id, [], $headers)
             ->assertStatus(204);
    }

    public function testArticlesAreListedCorrectly()
    {
        factory(Article::class)->create([
            'title' => 'First Article',
            'body' => 'First Body'
        ]);

        factory(Article::class)->create([
            'title' => 'Second Article',
            'body' => 'Second Body'
        ]);

        $user = factory(User::class)->create();
        $token = $user->generateToken();
        $headers = ['Authorization' => "Bearer $token"];

        $response = $this->json('GET', '/api/articles', [], $headers)
                         ->assertStatus(200)
                         ->assertJsonStructure([
                            '*' => ['id', 'body', 'title', 'created_at', 'updated_at'],
                         ]);
    }
}

最後,執行測試命令:

vendor\bin\phpunit

就可以對我們編寫的所有測試用例進行測試,當然也會對 Laravel 預設提供的兩個測試用例 ExampleTest 進行測試。

至此,我們已經完成了 API 介面的編寫和測試,下一篇我們會基於 JWT 對 API 進行認證,同時整合 Vue SPA 做一個更偏向實戰的教程。