案例 1.1 – 網誌經典版
我們將 koa 官方的 Blog 範例 修改之後,成為《BlogMVC 簡易網誌系統》並進行測試,您可以在下列網址看到 BlogMVC 專案完整的程式碼:
讓我們先展示一下該系統地執行結果,首先是執行畫面:
當我們按下《創建新貼文》連結後,會進入以下畫面:
接著填寫標題內容後送出,會回到執行畫面,然後顯示有一筆貼文:
當我們點進該貼文之後,會顯示貼文內容:
上述程式雖然已經建構完成了,但是在完成之後再透過對該程式進行分析,除了可以讓程式的結構更清楚,也可以趁機學會《系統分析與設計》的那些能力。
在此我們將採用 UML 的方式,對 BlogMVC 進行分析設計,以便學習完整的 《UML 分析、設計、實作、測試》的能力。
分析
UML 的使用個案圖,和 Scrum 中的 User Story 用途類似,只是表達方法不同,我們在此都做一遍。
User Story
- 使用者可以檢視全部貼文列表
- 使用者可以新增一筆貼文
- 貼文按照新增時間由舊到新列出
- 使用者可以檢視單一貼文內容
- 包含標題 title 與內容 content
使用個案圖
@startuml
left to right direction
使用者 --> (檢視貼文列表)
note right of (檢視貼文列表)
貼文按照新增時間由舊到新列出
end note
使用者 --> (新增貼文)
使用者 --> (檢視貼文內容)
note right of (檢視貼文內容)
包含標題 title 與內容 content
end note
@enduml
顯示結果
設計
BlogMVC 採用經典網站設計方式,也就是全部的資訊都在 Server 端生成後傳回,因此沒有用到《前端 JavaScript 技術》。
設計模式
在 BlogMVC 專案中我們採用 MVC (Model-View-Controller) 模式,其中的 Controller 就是主程式 Server.js。
- Model : model.js
- 資料的存取
- View : view.js
- 畫面的顯示
- Controller : Server.js
- 伺服器主程式,採用 koa 伺服端框架,負責調用 model 與 view
類別圖
@startuml
class Server extends Koa {
View V
Model M
+String list(ctx) \t// GET /
+String add(ctx) \t// GET /post/new
+String create(ctx) \t// POST /post
+String show(ctx) \t// GET /post/:id
}
class View {
+String layout(title, content)
+String list(posts)
+String new()
+String show(post)
}
class Model {
+Post[] posts
+{method} add(post)
+Post get(id)
+Post[] list()
}
class Post {
+String title
+String content
}
Server *-- Model : contains >
Server *-- View : contains >
Model "1"*--"many" Post : contains >
@enduml
呈現結果
循序圖
類別與函數都確定後,就可以用《循序圖》描述詳細的互動關係。
@startuml
使用者 -> Browser : 進入本網站
Browser -> Server : GET /
Server <-> Model : posts = M.list()
Server <-> View : html = V.list(posts)
Server -> Browser : html
Browser -> 使用者 : 貼文列表
...
使用者 -> Browser : 點選《建立新貼文》
Browser -> Server : POST /post/ body=post
Server -> Model : M.add(post)
Server -> Browser : redirect /
Browser -> 使用者 : 回到 / 顯示《貼文列表》
...
使用者 -> Browser : 從貼文列表點選第 id 筆貼文
Browser -> Server : GET /post/id
Server <-> Model : post = M.get(id)
Server -> View : html = V.show(post)
Browser <- Server : html
使用者 <- Browser : 顯示第 id 筆貼文
@enduml
呈現結果
BDD 測試
以上《簡易網誌系統》測試的《使用者故事》,若寫成 RESTful 形式,可以寫成下列故事:
- GET / :
- 傳回《貼文列表》\n目前只有 0 則貼文
- POST /post/ : body={ title: ‘貼文 0’, body: ‘內容 0’ }
- 應該會創建新貼文 (post 0),然後轉址到根目錄 / 後顯示《貼文列表》
- GET /post/0 :
- 應該會看到第 0 則貼文
若使用 UML 當中的《循序圖》(Sequence Diagram) 描述該案例,則可繪製出下列的《測試案例循序圖》:
@startuml
使用者 -> Browser : 進入網站
Browser -> Server : GET /
Server -> Browser : 顯示《貼文列表》
Browser -> 使用者 : <p>您總共有 <strong>0</strong> 則貼文!</p>
...
使用者 -> Browser : 輸入貼文\n{ title: '標題 0', body: '內容 0' }
Browser -> Server : POST /post/ 儲存新貼文\nbody={ title: '標題 0', body: '內容 0' }
Server -> Browser : 儲存貼文後轉址到 /
Browser -> 使用者 : / 顯示《貼文列表》
...
Browser -> Server : GET /post/0
Browser <- Server : 顯示第 0 則貼文
使用者 <- Browser : <h1>標題 0</h1>\n<p>內容 0</p>
@enduml
呈現結果
透過這樣的分析,要寫出測試程式 test.js 就容易了。
/* eslint-env mocha */
const expect = require('chai').expect
const server = require('./server').listen()
const request = require('supertest').agent(server)
describe('簡易網誌系統', function () {
after(function () {
server.close()
})
describe('GET /', function () { // 路徑 GET /
it('內文標題應該為《貼文列表》,而且只有 0 則貼文', function (done) {
request.get('/').expect(200, function (err, res) {
if (err) return done(err)
expect(res.header['content-type']).to.include('html') // 根目錄是個 html 文件
expect(res.text).to.include('<title>貼文列表</title>') // 內文標題為 Posts
expect(res.text).to.include('<p>您總共有 <strong>0</strong> 則貼文!</p>')
done()
})
})
})
describe('POST /post', function () { // 路徑 POST /post/new
it('應該會創建新貼文,然後轉址到根目錄 /', function (done) {
request
.post('/post')
.send({ title: '貼文 0', body: '內容 0' })
.end(function (err, res) {
if (err) return done(err)
expect(res.header.location).to.equal('/') // 路徑 / => 根目錄是個 html 文件
done()
})
})
})
describe('GET /post/0', function () {
it('應該會看到第 0 則貼文', function (done) {
request.get('/post/0').expect(200, function (err, res) {
if (err) return done(err)
expect(res.header['content-type']).to.include('html')
expect(res.text).to.include('<h1>貼文 0</h1>')
expect(res.text).to.include('<p>內容 0</p>')
done()
})
})
})
})
測試案例的執行結果:
PS D:\course\sejs\project\blogMvc> mocha
簡易網誌系統
GET /
<-- GET /
--> GET / 200 30ms 1.13kb
√ 內文標題應該為《貼文列表》,而且只有 0 則貼文 (116ms)
POST /post
<-- POST /post
--> POST /post 302 81ms 33b
√ 應該會創建新貼文,然後轉址到根目錄 / (97ms)
GET /post/0
<-- GET /post/0
--> GET /post/0 200 3ms 1.02kb
√ 應該會看到第 0 則貼文
3 passing (288ms)
實作
Server.js 的原始碼如下:
const V = require('./view')
const M = require('./model')
const logger = require('koa-logger')
const router = require('koa-router')()
const koaBody = require('koa-body')
const Koa = require('koa')
const server = module.exports = new Koa()
server.use(logger())
server.use(koaBody())
router.get('/', list)
.get('/post/new', add)
.get('/post/:id', show)
.post('/post', create)
server.use(router.routes())
async function list (ctx) {
const posts = M.list()
ctx.body = await V.list(posts)
}
async function add (ctx) {
ctx.body = await V.new()
}
async function show (ctx) {
const id = ctx.params.id
const post = M.get(id)
if (!post) ctx.throw(404, 'invalid post id')
ctx.body = await V.show(post)
}
async function create (ctx) {
const post = ctx.request.body
M.add(post)
ctx.redirect('/')
}
if (!module.parent) {
server.listen(3000)
console.log('Server run at http://localhost:3000')
}
而負責資料存取的 model 模組之原始碼如下:
const M = module.exports = {}
const posts = []
M.add = function (post) {
const id = posts.push(post) - 1
post.created_at = new Date()
post.id = id
}
M.get = function (id) {
return posts[id]
}
M.list = function () {
return posts
}
負責呈現畫面的 view 模組之原始碼如下:
var V = module.exports = {}
V.layout = function (title, content) {
return `
<html>
<head>
<title>${title}</title>
<style>
body {
padding: 80px;
font: 16px Helvetica, Arial;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.2em;
}
#posts {
margin: 0;
padding: 0;
}
#posts li {
margin: 40px 0;
padding: 0;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
list-style: none;
}
#posts li:last-child {
border-bottom: none;
}
textarea {
width: 500px;
height: 300px;
}
input[type=text],
textarea {
border: 1px solid #eee;
border-top-color: #ddd;
border-left-color: #ddd;
border-radius: 2px;
padding: 15px;
font-size: .8em;
}
input[type=text] {
width: 500px;
}
</style>
</head>
<body>
<section id="content">
${content}
</section>
</body>
</html>
`
}
V.list = function (posts) {
let list = []
for (let post of posts) {
list.push(`
<li>
<h2>${post.title}</h2>
<p><a href="/post/${post.id}">讀取貼文</a></p>
</li>
`)
}
let content = `
<h1>貼文列表</h1>
<p>您總共有 <strong>${posts.length}</strong> 則貼文!</p>
<p><a href="/post/new">創建新貼文</a></p>
<ul id="posts">
${list.join('\n')}
</ul>
`
return V.layout('貼文列表', content)
}
V.new = function () {
return V.layout('新增貼文', `
<h1>新增貼文</h1>
<p>創建一則新貼文</p>
<form action="/post" method="post">
<p><input type="text" placeholder="Title" name="title"></p>
<p><textarea placeholder="Contents" name="body"></textarea></p>
<p><input type="submit" value="Create"></p>
</form>
`)
}
V.show = function (post) {
return V.layout(post.title, `
<h1>${post.title}</h1>
<p>${post.body}</p>
`)
}
結語
以上是 BlogMVC 經典網誌的案例,應該能完整的展現《server 為主的網站系統》的《分析、設計、實作、與測試》過程。
這種經典網站可以採用 supertest 測試就行了,但是如果有使用 AJAX 技術,或者有《前端 JavaScript》程式的網站,就不能只採用 supertest 進行測試了,而必須要使用 Puppeteer 或 Selenium 等 Headless Browser 《隱藏式瀏覽器》了。