Laravel TDD-CI #Day 3

Warodom Werapun
http://warodom.werapun.com
8 min readJan 26, 2017

บทความนี้ ทีแรกกะว่าจะเขียนแค่ทำ test โดยใช้ phpunit (API test และ UI integration test) บน Laravel 5.3 ธรรมดา โดยยกตัวอย่างโปรแกรม FizzBuzz แต่ทำไป ๆ ก็บันทึกรวมไว้จนครบกระบวนความถึง Continuous Integration ด้วย Git / Docker / Jenkins ไปเรียบร้อย ก็เลยยาวหน่อย เริ่มเลยดีกว่า

FizzBuzz เป็น โปรแกรมที่มีเงื่อนไขการทำงานที่ง่าย ๆ คือ

  • หาร 3 ลงตัวให้แสดงค่า Fizz
  • หาร 5 ลงตัวให้แสดงค่า Buzz
  • หารทั้ง 3 และ 5 ลงตัวให้แสดง FizzBuzz
  • อื่น ๆ ให้แสดง OK

มาเขียน FizzBuzz เริ่มจากอะไรดี? ก็ต้องตามหลัก TDD เพื่อทำ System Test ก่อน

Test -> Write Code -> Refactor สินะ เริ่ม

php artisan make:test FizzBuzzTest

ทดลอง run Test

.\vendor\bin\phpunit 

หากสั่ง phpunit โดยไม่มีการระบุ class phpunit จะทดสอบ Test ทั้งหมดใน folder tests ซึ่งตอนนี้ ระบุ assertTrue(true); อยู่ จึง test ผ่านทั้งหมด ดังรูปที่แสดงข้างบน

คราวนี้ลองมาเขียน test fizz เอา case ง่ายที่สุด (test method ใน phpunit จำเป็นต้องขึ้นด้วย testXxxxMethod() )

use App\Http\Controllers\FizzBuzzController;class FizzBuzzTest extends TestCase
{
public function testInput3shouldReturnFizz()
{
$fizzBuzz = new FizzBuzzController();
$result = $fizzBuzz->process("3");
$this->assertEquals($result, "Fizz");
}
}

สร้าง controller FizzBuzz

php artisan make:controller FizzBuzzController

จากนั้นสร้างเขียน controller เพื่อทำให้ Test ผ่านดังนี้

class FizzBuzzController extends Controller
{
public function process($number) {
return "Fizz";
}
}

ทดลอง run test

.\vendor\bin\phpunit tests\FizzBuzzTest.php

จะเห็นว่าเราทำการ hardcode เพื่อ return ค่า Fizz ไปโดยตรง เนื่องจากว่า Test ที่ใช้ทดสอบ มีกรณีเดียว เราก็เขียนเพื่อให้ตอบสนองเฉพาะกรณี คราวนี้ พอมีกรณี 5 เพิ่มขึ้นมา เราก็เขียนใหม่เพิ่มไป …

public function testInput5shouldReturnBuzz()
{
$fizzBuzz = new FizzBuzzController();
$result = $fizzBuzz->process("5");
$this->assertEquals($result, "Buzz");
}

และ แก้ code เป็นดังนี้

class FizzBuzzController extends Controller
{
public function process($number) {
if ( $number % 3 )
return "Fizz";
return "Buzz";
}
}

… เราก็เพิ่ม test และ แก้ code ไปเรื่อย ๆ … จนสุดท้าย สิ่งสำคัญคือ เราค่อย ๆ เติม test เข้ามา และ ก็ใช้ test นำการเขียนโปรแกรมไปเรื่อย ๆ เขียนเสร็จแล้วก็ทดสอบระบบ

เริ่มเขียน GUI

Update controller โดยเพิ่ม 2 ฟังก์ชันนี้เข้าไปใน FizzBuzzController.php

public function index() {
return view('index');
}
public function fb(Request $request) { $result = $this->process($request->number);
return view('index')->with('result',$result)
->with('number',$request->number);
}

สร้าง resources\views\index.blade.php

<div class="container">  
<h1 class="text-xs-center">โปรแกรมฟิซบัส</h1>

<form action="/fb" method="POST" role="form">
<div class="form-group">
<label for="">Number:</label>
<input type="number" class="form-control" id="number" name="number" required value="{{ $number or '' }}">
<input type="hidden" name="_token" value="{{ csrf_token() }}" >
</div>

<button type="submit" name="submit" class="btn btn-primary">Submit</button>
</form>
<br/><br/>
<h2>Result: {{ $result or '' }} </h2>
</div>
  • เขียน HTML ให้ถูกครบ และ อ้างอิง CSS ให้ครบ

ทดลองเรียกหน้า web site อีกครั้ง ก็จะมี Fizz Buzz Web Page เป็นอันเสร็จพิธี

ลองมาเขียน Web Test

เริ่มจากสร้าง FizzBuzzWebTest ขึ้นมาใหม่

php artisan make:test FizzBuzzWebTest

จากนั้นเขียน test ที่ทดสอบดังนี้

public function testIndexPage()
{
$this->visit('/fb')
->see('Result')
->dontSee('FizzBuzz');
}

เป็นการเรียกหน้า /fb โดย Method GET แล้วตรวจสอบว่ามีข้อความ “Result” อยู่ในหน้าแรกหรือไม่ ถ้าการทดสอบผ่าน ในหน้า /fb จะต้องมีข้อความ Result และ จะต้องไม่มีข้อความ FizzBuzz ในหน้าแรก

การทดสอบข้อความเป็น case-insensitive (FizzBuzz มีค่าเท่ากับ FIZZBUZZ)

ลองทดสอบเพิ่มเติม โดยการจำลองการป้อน 3 ใน input text และหลังจากกด submit แล้ว จะต้องเจอข้อความ “Buzz” จะเห็นว่าข้อความที่เราใช้ตรวจสอบจะต้องไม่ซ้ำกับข้อความที่มีอยู่แล้วในหน้า Index (ในตัวนี้เลยตั้งชื่อว่า โปรแกรมฟิซบัส)

public function testInput3shouldReturnFizz()
{
$this->visit('/fb')
->type('3','number')
->press('submit')
->see('Buzz');
}

ทดสอบกรณีอื่น ๆ เพิ่มเติม

public function testInput5shouldReturnBuzz()
{
$this->visit('/fb')
->type('5','number')
->press('submit')
->see('Buzz');
}
public function testInput7shouldReturnOK()
{
$this->visit('/fb')
->type('7','number')
->press('submit')
->see('OK');
}
public function testInput15shouldReturnFizzBuzz()
{
$this->visit('/fb')
->type('15','number')
->press('submit')
->see('FizzBuzz');
}

ทดสอบการทำงาน

หลังจากนี้ หากมีการ debug program หรือเพิ่ม คุณสมบัติใหม่ อื่น ๆ เพิ่มเติม เราก็สามารถเรียกใช้ชุดทดสอบ เพื่อยืนยันว่าโปรแกรมเรายังสามารถทำงานได้ถูกต้อง ตามหลักการของ Test Driven Development (TDD) !!

PHP Code Coverage

เป็นเครื่องมือที่เอาไว้ตรวจสอบว่า Test ที่เราเขียนไว้ครอบคลุม Code ของเราเท่าไร ซึ่ง Laravel ที่ติดตั้งไว้ได้ รวม /vendor/phpunit/php-code-coverage/ ไว้แล้ว วิธีการใช้งาน ให้เพิ่ม configuration ในไฟล์ phpunit.xml ดังนี้

<phpunit>
...
<log type="coverage-html" target="./report" charset="UTF-8"
yui="true" highlight="true"
lowUpperBound="50" highLowerBound="80" />
</logging>
</phpunit>

จาก configure นี้ เป็นการกำหนดให้หา coverage test โดยเก็บ output ที่ /report

เมื่อสั่ง Test vendor/bin/phpunit tests/FizzBuzzTest.php จะเกิด Error: no code driver avaliable

เพราะว่า php7.1 ที่ load มา ไม่ได้ enable Xdebug มาด้วย

ตรวจสอบโดยสั่ง php -v

ให้ enable xDebug ดังนี้

ตรวจสอบ โดยสั่ง php -v อีกที Xdebug enable เรียบร้อยแล้ว

จากนั้นให้ run test อีกรอบ

จะได้ HTML output ใน folder /report

PHP Code Sniffer (phpcs)

PHP Code Sniffer เป็นโปรแกรมที่ช่วยจัดการ code ให้เรียบร้อยสวยงาม รวมถึงช่วยตรวจสอบข้อผิดพลาด

การติดตั้ง ใช้ composer download มาไว้ใน vendor ด้วยคำสั่ง (ถ้าใส่ global ด้วย มันจะไปลงที่ C:\Users\userName\AppData\Roaming\Composer)

composer require pragmarx/laravelcs

เมื่อติดตั้งเรียบร้อย สั่งให้ตรวจสอบข้อผิดพลาด

phpcs --standard=vendor\pragmarx\laravelcs\Standards\Laravel     app\Http\Controllers\FizzBuzzController.php

จะได้ผลลัพธ์ประมาณนี้ แจ้ง ERROR

ถ้าอันไหนมี [x] แสดงว่า สามารถใช้ phpcbf (code beatiful fix) ซ่อมได้ ดังนี้

phpcbf --suffix=.fix app\Http\Controllers\FizzBuzzController.php

ใช้ option --sufix เพื่อให้ ไฟล์ที่สร้างใหม่ ไม่ทับไฟล์เดิม แต่เป็น FizzBuzzController.php.suffix

ผลการใช้งาน (ด้านซ้าย original, ด้านขวา เกิดจากการใช้ phpcbf)

ยังไม่น่าประทับใจเท่าที่ควร เพราะบาง indent ที่เราจัดไว้ดีแล้ว เช่น ->with อยู่ตรงกัน หรือบางที เวลา if แล้วมีแค่ 1 statement ไม่อยากใส่เครื่องหมาย {} เป็นต้น แถมหลังจากใช้งาน phpcbf เสร็จ ไปใช้ phpcs พบว่า ERROR เพิ่มขึ้นมากกว่าเดิม ไม่แน่ใจว่า เพราะตัว phpcs และ phpcbf ไม่ตาม Coding style แบบ PSR-2 ทั้งหมด หรือไม่งั้นก็ configure ตรงไหน ผิดสักแห่งละ

Upload ขึ้น GitHub

  • go to github page และสร้าง project
git init
git add .
git commit -m "First commit laravel project"
git remote add origin https://github.com/wwarodom/blog.git
git pull origin master --allow-unrelated-histories
git push -u origin master
  • ตอนนี้ ไฟล์ทั้งหมดก็จะอยู่ใน https://github.com/wwarodom/blog หากต้องการแก้ไขไฟล์
git branch dev
git checkout dev

แก้ไฟล์ตามต้องการ….

git add .
git commit -m "feature / bug fix messages"
git checkout master
git fetch
git status
git merge --no-ff dev

หากเกิด conflict ให้เลือก การแก้ไขที่ต้องการ แล้วสั่ง add/ commit/ merge ใหม่อีกรอบ

Download จาก Github เตรียม deploy

ดึงไฟล์จาก remote master repository

  • สร้าง folder ใหม่ เสมือนว่าเป็น folder ใน production server
git init
git clone https://github.com/wwarodom/blog.git
  • เนื่องจาก laravel มีการกำหนดไฟล์ .env และ /vendor folder ไว้ใน .gitignore เพื่อไม่ให้ upload ไฟล์เหล่านี้ขึ้น server จึงจำเป็นต้อง สร้าง .env ขึ้นมาใหม่ ส่วน /vendor ให้ใช้
composer update
  • composer update เพื่อ download /vendor folder (ที่ กำหนดใน composer.lock) ถ้าใช้ composer install จะ download package ใน composer.json ซึ่งอาจจะมีบาง package มี version ย่อยไม่ตรงกันกับที่ dev ทั้งหมด

แค่นี้ก็เป็น folder พร้อมจะ deploy ขึ้น production server จริง ๆ

Run test ก่อน deploy

  • vendor\bin\phpunit

ใน windows ต้องใช้เครื่องหมาย \ ถ้าใช้ / จะหา path ไม่เจอ

ถ้า test ผ่านก็เตรียม deploy ลง docker@server

Docker (Deploy ขึ้น Production Server)

  • map production folder ให้ virtual box (default) มองเห็น
  • เปิด docker termial (MINGW64)
  • Login เข้าไปใน vvm โดยใช้
    docker-machine.exe ssh default
  • หลังจากที่เข้าไปใน vm default แล้ว สร้าง folder ที่ใช้ mount โดย
    sudo mkdir /mnt/production
  • mount folder ที่ shared ไว้ โดยคำสั่ง
    sudo mount -t vboxfs production /mnt/production
  • exit ออกจาก default
  • ลองสั่ง ls แสดงว่า mount ได้เรียบร้อย

หาก vm พัง

$ docker-machine rm default 
$ docker-machine create --driver virtualbox default
$ docker-machine env --shell cmd default

เตรียมติดตั้ง Images และ Containers

  • ใช้ docker images ตรวจสอบ images ที่มี

Step 1: MySQL

docker pull mysql:5.7.17docker run --name mysql -v /mnt/production/data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=password -d mysql:5.7.17docker ps

ถ้าแสดงผลแบบข้างล่างเป็นอันเรียบร้อย

Step2: PHP

ติดตั้งและ start PHP เพียง 2 ขั้นตอน เสร็จ

docker pull php:7.1.0-fpm
docker run --name php --link mysql:5.7.17 -v /mnt/production/blog:/var/code -d php:7.1.0-fpm

เนื่องจาก image ของ nginx มี folder /usr/share/nginx/html เป็น web root directory แต่ของ phpfpm ใช้ folder /var/www/html เป็น web root ใน configure ของ nginx จะมีการโยนไฟล์ โดย map จาก request ที่ส่งมา โยนให้ php-fpm ทำงาน ดังนั้น ชื่อ folder จำเป็นต้องตรงกัน ก็เลยแก้ปัญหาโดยให้ map ไปที่ /var/code แทน

Optional: หากต้องการรวม composer ไว้ใน php:7.1.0-fpm จะต้องสร้าง php image มาใหม่เอง โดย extends มาจาก php:7.1.0-fpm

  • ในส่วนของ php extension นั้น php 7.1.0 จัดมาให้เกือบครบตั้งแต่แรกแล้ว ไม่ว่าจะเป็น curl, gd, mbstring, pdo_mysql, openssl, mysqli, sqlite, xdebug แทบจะไม่ต้องติดตั้ง modules อะไรเพิ่มเติม
  • เอาแค่ composer กับ php.ini โดยเขียน Dockerfile ดังนี้
FROM php:7.1.0-fpm
MAINTAINER Warodom Werapun <warodom.w@psu.ac.th>
# Install composer
RUN curl -sS https://getcomposer.org/installer | php && mv composer.phar /usr/local/bin/composer
# Setup timezone
RUN cat /usr/src/php/php.ini-production | sed 's|^;\(date.timezone.*\)|\1 \"Etc\/UTC\"|' > /usr/local/etc/php/php.ini
# Disable cgi.fix_pathinfo
RUN sed -i 's|;\(cgi\.fix_pathinfo=\)1|\10|' /usr/local/etc/php/php.ini
WORKDIR /var/www

จากนั้นสั่ง

$ cd /path/to/Dockerfile
$ docker build -t my-php-fpm .

เพื่อได้ docker my-php-fpm image มาใหม่

Step3: Nginx
docker pull nginx:1.11.8

หาก run ผิด container แล้วต้องการจะหยุดการทำงาน เช่น stop nginx container ใช้คำสั่ง docker rm -f $(docker ps -aq -f name=nginx)

หาก start container ได้ และจะเข้าไปใน container ใช้ docker exec -it nginx bash

ก่อน start ให้สร้าง configuration ใหม่ ให้กับ nginx ใน ./conf.d/default.conf ดังนี้

server {
listen 80;
index index.html index.php;
sendfile off;
root /var/code;
server_name localhost;

location / {
try_files $uri $uri/ /index.php?$query_string;
}

location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}

start nginx

docker run --name nginx \ 
--link php:7.1.0-fpm \
-v /mnt/production/blog:/var/code \
-v /mnt/production/conf.d:/etc/nginx/conf.d:ro \
-p 80:80 \
-d nginx:1.11.8

Docker compose

เอาทั้งหมดมารวมกันในคำสั่งเดียว ด้วย docker-compose

สร้างไฟล์ /production/compose/docker-compose.yml

version: '2'
services:
### MySQL Container #########################################
mysql:
image: mysql:5.7.17
environment:
- MYSQL_DATABASE=dev
- MYSQL_USER=root
- MYSQL_PASSWORD=password
- MYSQL_ROOT_PASSWORD=password
volumes:
- /mnt/production/data:/var/lib/mysql
ports:
- "3306:3306"
container_name: mysql
### PHP-FPM Container #######################################
php:
image: php:7.1.0-fpm
volumes:
- /mnt/production/blog:/var/code
depends_on:
- mysql
links:
- mysql
container_name: php
### Nginx Server Container ##################################
nginx:
image: nginx:1.11.8
volumes_from:
- php
volumes:
- /mnt/production/blog:/var/code
- /mnt/production/conf.d:/etc/nginx/conf.d
ports:
- "80:80"
- "443:443"
links:
- php
container_name: nginx

จากนั้นสั่ง docker-compose up -d ถ้าเรียบร้อย container ทั้งหมดจะต้อง run ขึ้นมา สั่ง docker psเพื่อตรวจสอบ

เมื่อต้องการ เลิก service ให้สั่ง docker-composer down container จะถูกทำลาย

Jenkins for Continuous Integration

เอาทุกอย่างมายำรวมให้อัตโนมัติ ใน click เดียว ประกอบด้วย

  • Build Trigger: เมื่อมีการ update master branch แล้วส่ง web hook (HTTP POST) มาให้ jenkins build แต่ เครื่องที่ทดสอบไม่สามารถเข้าถึงจาก github ก็เลยไม่ได้ทดสอบตรงนี้
  • ดึง code จาก repository git pull origin master
  • update library ต่าง ๆ composer update
  • ทดสอบ ก่อนขึ้น production server vendor\bin\phpunit
  • ในการขึ้น production server เนื่องจากใช้ docker-toolbox เพื่อความง่ายในการทดลอง แต่ jenkins run เป็น batch script ของ cmd windows shell ดังนั้น จึงต้องมีการใช้ docker-machine env default --shell cmd (default คือชื่อของ docker virtual machine host) เพื่อ set ค่า environment parameter ใหม่ ให้กับ cmd shell จากนั้นสั่ง down container และ up container ตาม script
  • ต้องแยก script windows batch ออกเป็น 3 ชุด [ถ้าใส่ชุดเดียวกัน คำสั่งที่เหลือไม่ run ไม่แน่ใจว่าเพราะอะไร]
  1. update code and library
e:
cd nginx-1.11.8\html\production\blog
git pull origin master
composer install -vvv --profile

2. test and report

e:
cd nginx-1.11.8\html\production\blog
vendor\bin\phpunit

3. docker deploy (ในขั้นนี้ ตัวแปร %%i จะต้องใช้ % 2 อัน สำหรับ escape sequence ถ้า run ตรง ๆ ใน cmd ใช้ % อันเดียว)

e:
cd nginx-1.11.8\html\production\compose
SET DOCKER_TLS_VERIFY=1
SET DOCKER_HOST=tcp://192.168.99.100:2376
SET DOCKER_CERT_PATH=C:\Users\wwaro\.docker\machine\machines\default
SET DOCKER_MACHINE_NAME=default
@FOR /f "tokens=*" %%i IN ('docker-machine env default --shell cmd') DO @%%idocker-compose down
docker-compose up -d
docker ps

มีเรื่อง docker swarm/ horizontal scaling แต่เอาแค่นี้พอก่อนนะ…

--

--