---
# System prepended metadata

title: Automated Headless Browser Testing
tags: [JavaScript, 'Node JS: Advanced Concepts', NodeJS]

---

# Automated Headless Browser Testing

### Global Jest Setup

可以在進行所有測試前對 mongoose 做最基本的 setup。

Setup

```javascript
// test/setup
// 設定每個 test 的逾時秒數，超過該時現限者會被判定為失敗
jest.setTimeout(30000)

require('../models/User')

const mongoose = require('mongoose')
const keys = require('../config/keys')

mongoose.Promise = global.Promise
mongoose.connect(keys.mongoURI, { useMongoClient: true })
```

package.json

```json
{
  "jest": {
    "setupTestFrameworkScriptFile": "./test/setup.js"
  }
}
```

### Session Signatures

簽章 sig 的存在主要是避免使用者隨意篡改 session 的資料，若有惡意使用者在不知道 sig 是透過什麼 secret 被 hash 出來的情況下竄改 session，他終究會在審核身份的 middleware 被阻擋。
```
set-cookie:session=SESSION; path=/; expires=Fri 23, July 2021 21:40:35 GTM; httponly
set-cookie:session.sig=SESSION_SIGNATURE; path=/; expires=Fri 23, July 2021 21:40:35 GTM; httponly
```

Session + Cookie Signing Key = Session Signature

```javascript
const Keygrip = require('keygrip')

// 考量安全性，cookieSigningKey 通常會被放在專案的 secret 避免被駭
const cookieSigningKey = 'this is a secret'
const keygrip = new Keygrip([cookieSigningKey])

const session = 'SESSION'
const sessionSignature = keygrip.sign('session=' + session)
const maliciousSessionSignature = 'surprise motherf*cker'

// 判斷是否為合法使用者
keygrip.verify('session=' + session, sessionSignature) // true
keygrip.verify('session=' + session, maliciousSessionSignature) // false
```

### Factory Functions

User Factory - 利用 mongoose 製造假的使用者

```javascript
// test/factories/userFactory
const mongoose = require('mongoose')
// 由於 test setup 已處理過 mongo 連線且載入 User model 所以不會報錯
const User = mongoose.model('User')

module.export = () => new User({}).save()
```

Session Factory - 利用假的使用者製造假的 sig 和 session

```javascript
// test/factories/sessionFactory
const Buffer = require('safe-buffer').Buffer
const Keygrip = require('keygrip')
const kes = require('../../config/keys')
const keygrip = new Keygrip([keys.cookieKey])

module.export = (user) => {
  const sessionObject = {
    passport: {
      user: user._id.toString()
    }
  }
  const session = Buffer
    .from(JSON.stringify(sessionObject))
    .toString('base64')
  const sig = keygrip.sign('session=' + session)
  
  return { session, sig }
}
```

### Helper Functions

若想**避免劫持造成第三方套件的 `Class` 被污染**，可以嘗試使用 `Class` 並回傳一個 `Proxy` 決定讀取 property 的優先級。BTW，原型鏈其實也可以達到相近的效果，不過最大的差別是用 Proxy 你可以**自訂義物件的優先級**，即使沒上下關係的物件也可以被列入參考；反之，原型鏈的話必須順著繼承的順序尋找 property。

Page

```javascript
// test/helpers/page
const puppeteer = require('puppeteer')
const userFactory = require('../factories/userFactory')
const sessionFactory = require('../factories/sessionFactory')

class CustomPage {
  static async build () {
    const browser = await puppeteer.launch({
      headless: false
    })
    
    const page = await browser.newPage()
    const customPage = new CustomPage(page)
    
    return new Proxy(customPage, {
      get: function (target, property) {
        return customPage[property] || browser[property] || page[property]
      }
    })
  }

  constructor (page) {
    this.page = page
  }
  
  login () {
    const user = await userFactory()
    const { session, sig } = sessionFactory(user)
  
    await this.page.setCookie({ name: 'session', value: session })
    await this.page.setCookie({ name: 'session.sig', value: sig })
    await this.page.goto('localhost:3000/blogs')
    // 等指定 selector 的 element 生成後才繼續執行
    await this.page.waitFor('a[href="/auth/logout"]')
  }
  
  async getContentsOf (selector) {
    return this.page.$eval(selector, el => el.innerHTML)
  }
  
  get (path) {
    return this.page.evaluate(
      _path =>
        fetch({
          path: _path,
          method: 'GET',
          credentials: 'same-origin',
          headers: {
            'Content-Type': 'application/json'
          }
        })
          .then(res => res.json()),
      path
    )
  }
  
  post (path, data) {
    return this.page.evaluate(
      (_path, _data) => 
        fetch({
          path: _path,
          method: 'POST',
          credentials: 'same-origin',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(_data)
        })
          .then(res => res.json()),
      path,
      data
    )
  }

  execRequests (actions) {
    return Promise.all(
      actions.map(({ method, path, data }) => this[method](path, data))
    )
  }
}

module.export = CustomPage
```

### Practice

Header Test

```javascript
// test/helpers/header.test
const Page = require('./helpers/page')

let page

beforeEach(async () => {
  page = await Page.build()
  await page.goto('localhost:3000')
})

afterEach(async () => {
  await page.close()
})

test('the header has the correct text', async () => {
  const text = await page.getContentsOf('a.brand-logo')
  expect(text).toEqual('Blogster')
})

test('clicking login starts oauth flow', async () => {
  await page.click('.right a')

  const url = await page.url()
  
  expect(url).toMatch(/accounts\.google\.com/)
})

test('When signed in, shows logout button', async () => {
  await page.login()
  
  const text = await page.getContentsOf('a[href="/auth/logout"]')
  
  expect(text).toEqual('Logout')
})
```

Blogs Test

```javascript
const Page = require('./helpers/page')

let page

beforeEach(async () => {
  page = await Page.build()
  await page.goto('localhost:3000')
})

afterEach(async () => {
  await page.close()
})

describe('When logged in', async () => {
  beforeEach(() => {
    await page.login()
    await page.click('a.btn-floating')
  })

  test('can see blog create form', async () => {
    const label = await page.getContentsOf('form label')
  
    expect(label).toEqual('Blog Title')
  })
  
  describe('And using valid inputs', async () => {
    beforeEach(async () => {
      // 模擬輸入表單並提交
      await page.type('.title input', 'My Title')
      await page.type('.content input', 'My Content')
      await page.click('form button')
    })
    
    test('Submitting takes user to review screen', async () => {
      const text = await page.getContentsOf('h5')
      
      expect(text).toEqual('Please confirm your entries')
    })
    
    test('Submitting then saving add blog to index page', async () => {
      await page.click('button.green')
      await page.waitFor('.card')
      
      const title = await page.getContentsOf('.card-title')
      const content = await page.getContentsOf('p')
      
      expect(title).toEqual('My Title')
      expect(content).toEqual('My Content')
    })
  })
  
  describe('And using invalid inputs', async () => {
    beforeEach(async () => {
      await page.click('form button')
    })
    
    test('The form shows an error message', async () => {
      const titleError = await page.getContentsOf('.title .red-text')
      const contentError = await page.getContentsOf('.content .red-text')
      
      expect(titleError).toEqual('You must provide a value')
      expect(contentError).toEqual('You must provide a value')
    })
  })
})

describe('User is not logged in', async () => {
  const actions = [
    {
      method: 'get',
      path: '/api/blogs/'
    },
    {
      method: 'get',
      path: '/api/blogs/',
      data: {
        title: 'T',
        content: 'C'
      }
    }
  ]

  test('Blog related actions are prohibited', async () => {
    // 模擬進入頁面後直接打請求
    const results = await page.execRequests(actions)

    for (let result of results) {
      expect(result).toEqual({ error: 'You must log in!' })
    }
  })
})
```

###### tags: `Node JS: Advanced Concepts` `NodeJS` `JavaScript`
