陳鍾誠

Version 1.0

案例 2 – AJAX 網誌

我們將前一個 BlogMVC 《簡易網誌系統》 修改成為 Web2.0 作法的 AJAX 系統,新專案網址如下:

其原始碼分為前端與後端,前後端之間透過 fetch API 以 JSON 格式進行溝通。

後端: Server.js

後端 Server.js 的程式碼如下:

const logger = require('koa-logger')
const router = require('koa-router')()
const koaBody = require('koa-body')
const koaJson = require('koa-json')
const koaStatic = require('koa-static')

const Koa = require('koa')
const app = (module.exports = new Koa())

// "database"

const posts = [] // {id: 0, title: 'aaa', body: 'aaa'}, {id: 1, title: 'bbb', body: 'bbb'}

// middleware

app.use(logger())

// app.use(render)

app.use(koaBody())
app.use(koaStatic('./public'))

// route definitions

router.get('/list', list).get('/post/:id', show).post('/post', create)

app.use(router.routes())
app.use(koaJson())

/**
 * Post listing.
 */

async function list (ctx) {
  // console.log('list: posts=%j', posts)
  ctx.body = posts
}

/**
 * Show post :id.
 */

async function show (ctx) {
  const id = ctx.params.id
  const post = posts[id]
  if (!post) ctx.throw(404, 'invalid post id')
  ctx.body = post
}

/**
 * Create a post.
 */

async function create (ctx) {
  var post = JSON.parse(ctx.request.body)
  const id = posts.push(post) - 1
  console.log('create:id=>', id)
  console.log('create:get=>', post)
  post.created_at = new Date()
  post.id = id
  console.log('create:save=>', post)
  // ctx.redirect('/')
}

// listen
if (!module.parent) {
  app.listen(3000)
  console.log('Server run at http://localhost:3000')
}

前端: index.html

<html>
  <head>
    <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">
    </section>
    <script src="main.js"></script>
  </body>
</html>

前端: main.js

前端本身就採用 MVC 架構,程式碼如下:

const blog = {
  controller : window,
  view: {},
  model: {}
}

// const R = {}

window.onhashchange = async function () {
  var r
  var tokens = window.location.hash.split('/')
  console.log('tokens=', tokens)
  switch (tokens[0]) {
    case '#show':
      let post = await blog.model.getPost(tokens[1])
      blog.view.show(post)
      break
    case '#new':
      blog.view.new()
      break
    default:
      let posts = await blog.model.list()
      blog.view.list(posts)
      break
  }
}

window.onload = function () {
  window.onhashchange()
}

blog.view.layout = function (title, content) {
  document.querySelector('title').innerText = title
  document.querySelector('#content').innerHTML = content
}

blog.view.list = function (posts) {
  let list = []
  for (let post of posts) {
    list.push(`
    <li>
      <h2>${post.title}</h2>
      <p><a id="show${post.id}" href="#show/${post.id}">Read post</a></p>
    </li>
    `)
  }
  let content = `
  <h1>Posts</h1>
  <p>You have <strong>${posts.length}</strong> posts!</p>
  <p><a id="createPost" href="#new">Create a Post</a></p>
  <ul id="posts">
    ${list.join('\n')}
  </ul>
  `
  return blog.view.layout('Posts', content)
}

blog.view.new = function () {
  return blog.view.layout('New Post', `
  <h1>New Post</h1>
  <p>Create a new post.</p>
  <form>
    <p><input id="title" type="text" placeholder="Title" name="title"></p>
    <p><textarea id="body" placeholder="Contents" name="body"></textarea></p>
    <p><input id="savePost" type="button" onclick="blog.model.savePost()" value="Create"></p>
  </form>
  `)
}

blog.view.show = function (post) {
  return blog.view.layout(post.title, `
    <h1>${post.title}</h1>
    <p>${post.body}</p>
  `)
}

blog.model.savePost = async function () {
  let title = document.querySelector('#title').value
  let body = document.querySelector('#body').value
  let r = await window.fetch('/post', {
    body: JSON.stringify({title: title, body: body}),
    method: 'POST'
  })
  window.location.hash = '#list'
  return r
}

blog.model.getPost = async function (id) {
  let r = await window.fetch('/post/' + id)
  let post = await r.json()
  return post
}

blog.model.list = async function () {
  let r = await window.fetch('/list/')
  let posts = await r.json()
  return posts  
}

測試程式

由於 Web2.0 以 AJAX 的作法,有很多前端 JavaScript 程式,因此若用 supertest 只能測試到 server.js ,無法測試到前端的 main.js,因此我們得改用 Puppeteer 或 Selenium 這類的《無頭瀏覽器》(headless browser) 進行前端測試,在此我們採用 Puppeteer 示範:

/* eslint-env mocha */
const ok = require('assert').ok
const app = require('./server').listen(3000)
const puppeteer = require('puppeteer')
var browser, page

const opts = {
  // headless: false,
  slowMo: 100,
  timeout: 10000
}

describe('blogAjax', function () {
  before(async function () {
    browser = await puppeteer.launch(opts)
    page = await browser.newPage()
  })
  after(function () {
    browser.close()
    app.close()
  })

  describe('puppeteer', function () {
    it('GET / should see <p>You have <strong>0</strong> posts!</p>', async function () {
      await page.goto('http://localhost:3000', {
        waitUntil: 'domcontentloaded'
      })
      let html = await page.content()
      ok(html.indexOf('<p>You have <strong>0</strong> posts!</p>') >= 0)
    })
    it('click createPost link', async function () {
      await page.click('#createPost')
      let html = await page.content()
      ok(html.indexOf('<h1>New Post</h1>') >= 0)
    })
    it('fill {title:"aaa", body:"aaa"}', async function () {
      await page.focus('#title')
      await page.keyboard.type('aaa')
      await page.focus('#body')
      await page.keyboard.type('aaa')
      await page.click('#savePost')
    })
    it('should see <p>You have <strong>1</strong> posts!</p>', async function () {
      let html = await page.content()
      ok(html.indexOf('<p>You have <strong>1</strong> posts!</p>') >= 0)
    })
    it('should see <p>You have <strong>1</strong> posts!</p>', async function () {
      await page.click('#show0')
      let html = await page.content()
      ok(html.indexOf('<h1>aaa</h1>') >= 0)
    })
  })
})

測試結果如下:

PS D:\ccc\book\sejs\project\blogAjax> npm run test

> blogajax@0.0.1 test D:\ccc\book\sejs\project\blogAjax
> mocha --timeout 100000

  blogAjax
    puppeteer
  <-- GET /
  --> GET / 200 317ms 1.04kb
  <-- GET /main.js
  --> GET /main.js 200 252ms 2.33kb
  <-- GET /list/
  --> GET /list/ 200 10ms 2b
      √ GET / should see <p>You have <strong>0</strong> posts!</p> (1338ms)
      √ click createPost link (880ms)
  <-- POST /post
create:id=> 0
create:get=> { title: 'aaa', body: 'aaa' }
create:save=> { title: 'aaa',
  body: 'aaa',
  created_at: 2018-11-09T08:18:45.382Z,
  id: 0 }
  --> POST /post 404 133ms -
  <-- GET /list/
  --> GET /list/ 200 5ms 77b
      √ fill {title:"aaa", body:"aaa"} (2849ms)
      √ should see <p>You have <strong>1</strong> posts!</p> (105ms)
  <-- GET /post/0
  --> GET /post/0 200 6ms 75b
      √ should see <p>You have <strong>1</strong> posts!</p> (680ms)


  5 passing (15s)

由於 Puppeteer 包含了一整個瀏覽器在裡面,啟動和執行速度都比較慢,因此在測試時會被 mocha 誤認為當機超時,所以必須要加入 –timeout 參數,才不會因測試太久而導致失敗!

所以我們在 package.json 裏的測試指令是: mocha –timeout 100000,完整的 package.json 如下所示:

{
  "name": "blogajax",
  "version": "0.0.1",
  "description": "AJAX 型的網誌",
  "main": "server.js",
  "dependencies": {
    "koa": "^2.5.3",
    "koa-body": "^4.0.4",
    "koa-json": "^2.0.2",
    "koa-logger": "^3.2.0",
    "koa-router": "^7.4.0",
    "koa-static": "^5.0.0"
  },
  "devDependencies": {
    "puppeteer": "^1.9.0"
  },
  "scripts": {
    "test": "mocha --timeout 100000",
    "start": "node server.js"
  },
  "author": "ccc",
  "license": "MIT"
}

結語

有了 Puppeteer 這樣的測試工具,我們就可以完整的測試前後端的程式碼,而不需總是用人手動花費大量時間去進行測試了!