Laravel TDD-CI #Day 3
บทความนี้ ทีแรกกะว่าจะเขียนแค่ทำ 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 ดังนี้
- download: https://xdebug.org/files/php_xdebug-2.5.0-7.1-vc14-nts-x86_64.dll (Non ThreadSafe/ 64 bits)
- Copy ใส่ใน php extension folder: (E:\php710nts\ext)
- แก้ไข php.ini โดยเพิ่มในส่วนของ extension (ต้องใช้คำว่า zend_extension)
ตรวจสอบ โดยสั่ง 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.iniWORKDIR /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 ไม่แน่ใจว่าเพราะอะไร]
- 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 แต่เอาแค่นี้พอก่อนนะ…