13.《Electron 跨平台開發實戰》- chapter13 使用 Spectron 測試應用


設置Spectron和測試運行器

安裝 spectron和mocha

cnpm install --save-dev spectron
cnpm install --save-dev mocha
  ...
  "devDependencies": {
    "mocha": "^8.0.1",
    "spectron": "^11.0.0"
  }

spectron :

https://github.com/electron-userland/spectron#accessibility-testing

mocha

官網:https://mochajs.org/
https://www.jianshu.com/p/4f7731b1a40b

assert

http://nodejs.cn/api/assert.html

創建一個Mocha的腳本

在package.json 文件創建一個Mocha的腳本


  "scripts": {
    "start": "electron .",
    "test": "mocha" //創建一個Mocha的腳本,運行本地的安裝的Mocha
  },
  ...

編寫Spectron測試代碼

項目結構

      |__Clipmaster-charp13-Sperctron
         |__ app
           |__ main.js
           |__ index.html
           |__ renderer.js
           |__ ...
         |__ package.json
         |__ test
             |__ spec.js 

運行 cnpm test 指令時, Mocha框架默認執行根目錄下test文件夾內所有的JavaScript文件,所有我們
新建./test/spec.js:配合測試運行器(Mocha)編寫Spectron測試代碼

  • spec.js 文件
const assert = require('assert'); //引入Nodde 內置的斷言庫
const path = require('path'); //引入Nodde 文集路徑輔助工具
const Application = require('spectron').Application; //引入Spectron的應用驅動程序
const electronPath = require('electron'); //已入electron,這讓我們可以訪問本地的Electron的開發版本

const app = new Application({
    path: electronPath,  //創建Spectron的Application對象,告訴他使用本地的Electron開發版本
    // The following line tells spectron to look and use the main.js file
    // and the package.json located 1 level above.
    //應用自身的根目錄作為應用的起始點和當前項目package.json文件路徑
    args: [path.join(__dirname, '..')] 
});

//mocha:定義一組測試
describe('Clipmaster 9000', function () {
    this.timeout(10000); //由於應用程序需要花費一些時間,因此增加Mocha的默認超時時間

    beforeEach(() => {
        return app.start(); //在每個應用之前啟動應用
    });

    afterEach(() => {
        if (app && app.isRunning()) {
            return app.stop();  //結束每個測試后停止應用
        }
    });

    //定義一個測試(一個測試用例)
    it ('啟動一個窗口', async function(){
        let count  = await app.client.getWindowCount();
        return assert.equal(count, 1);
    })
});
  • mocha只有兩個主要的api。
    • describe(name, fn) 定義一組測試
    • it(name, fn) 定義一項測試

運行測試:cnpm test

PS E:\Kzone\CodeLib\electron\electron-action\Clipmaster-charp13-Sperctron> cnpm test

> clipmaster-9000@1.0.0 test E:\Kzone\CodeLib\electron\electron-action\Clipmaster-charp13-Sperctron
> mocha
  Clipmaster 9000
    √ 啟動一個窗口

  1 passing (6s)

1 passing (6s) 測試用例通過

測試標題是否正確

  • 測試代碼
//mocha:定義一組測試
describe('Clipmaster 9000', function () {
    ...
    it('窗口標題是否正確', async () =>{
        //waitUntilWindowLoaded等待窗口加載完html、css、js后獲取標題
        let title = await app.client.waitUntilWindowLoaded().getTitle();
        return assert.equal(title, 'Clipmaster 9000');
    })
});
  • 運行測試
PS E:\Kzone\CodeLib\electron\electron-action\Clipmaster-charp13-Sperctron> cnpm test 

> clipmaster-9000@1.0.0 test E:\Kzone\CodeLib\electron\electron-action\Clipmaster-charp13-Sperctron
> mocha

  Clipmaster 9000
    √ 啟動一個窗口
    √ 窗口標題是否正確

  2 passing (9s)
  • 測試不通過
    把斷言改為
     return assert.equal(title, 'Clipmaster');
PS E:\Kzone\CodeLib\electron\electron-action\Clipmaster-charp13-Sperctron> cnpm test

> clipmaster-9000@1.0.0 test E:\Kzone\CodeLib\electron\electron-action\Clipmaster-charp13-Sperctron
> mocha
  Clipmaster 9000
    √ 啟動一個窗口
    1) 窗口標題是否正確

  1 passing (9s)
  1 failing

  1) Clipmaster 9000
       窗口標題是否正確:

      AssertionError [ERR_ASSERTION]: 'Clipmaster 9000' == 'Clipmaster'
      + expected - actual

      -Clipmaster 9000
      +Clipmaster

      at Context.<anonymous> (test\spec.js:37:23)
      at processTicksAndRejections (internal/process/task_queues.js:97:5)

npm ERR! Test failed.  See above for more details.


測試 Electron BrowseWindow API

檢測應用加載后,開發者工具是否處於關閉狀態
    it('不要打開開發者工具', async () => {
        let devToolsAreOpen = await app.client
            .waitUntilWindowLoaded()
            .browserWindow.isDevToolsOpened();
        return assert.equal(devToolsAreOpen, false);
    })

使用Spectron遍歷和測試DOM


 it('應用啟動是不存在剪貼項', async () => {
        await app.client.waitUntilWindowLoaded();
        let clippings = await app.client.$$('.clippings-list-item'); //document.querySelectorAll
        return assert.equal(clippings.length, 0);
    })

    it('點擊"copy-from-clipboard"按鈕時,增加一項剪貼項', async () => {
        await app.client.waitUntilWindowLoaded();
        await app.client.click("#copy-from-clipboard");
        let clippings = await app.client.$$('.clippings-list-item');
        return assert.equal(clippings.length, 1);
    })

    it('可以刪除剪切項', async () => {
        await app.client.waitUntilWindowLoaded();
        await app.client.click("#copy-from-clipboard") //先添加一項
            .moveToObject(".clippings-list-item")//默認Remove按鈕是隱藏的,不能點擊,故先將鼠標指針移動到DOM元素上
            .click(".remove-clipping");//刪除
        let clippings = await app.client.$$('.clippings-list-item');
        return assert.equal(clippings.length, 0);
    })

獲取DOM

let clippings = await app.client.$$('.clippings-list-item'); //document.querySelectorAll

測試單擊交互操作

 await app.client.click("#copy-from-clipboard");

在測試中移動鼠標

       await app.client.click("#copy-from-clipboard") //先添加一項
            .moveToObject(".clippings-list-item")//默認Remove按鈕是隱藏的,不能點擊,故先將鼠標指針移動到DOM元素上
            .click(".remove-clipping");//刪除

使用 Sepctron控制Electron API

從剪貼板復制並顯示正確的文本

    it('從剪貼板復制並顯示正確的文本', async () => {
        let testText = 'Hello Word';
        await app.client.waitUntilWindowLoaded();
        await app.electron.clipboard.writeText(testText);
        await app.client.click("#copy-from-clipboard");
        let clippingText = await app.client.getText('.clipping-text');

        return assert.equal(clippingText, testText);
    })

寫剪貼板是否正確

    it('寫剪貼板是否正確', async () => {
        //從剪貼板復制文本
        let testText = 'Hello Word';
        await app.client.waitUntilWindowLoaded();
        await app.electron.clipboard.writeText(testText);
        await app.client.click("#copy-from-clipboard");

        //模擬剪貼板有新的內容
        await app.electron.clipboard.writeText('剪貼板新內容');

        //寫剪貼板
        await app.client.click('.copy-clipping');
        let clippingText = await app.electron.clipboard.readText();

        return assert.equal(clippingText, testText);
    })

完整代碼

package.json

{
  "name": "clipmaster-9000",
  "version": "1.0.0",
  "description": "A menubar application with a rich UI.",
  "main": "app/main.js",
  "scripts": {
    "start": "electron .",
    "test": "mocha"
  },
  "author": "weikai",
  "license": "MIT",
  "dependencies": {
    "dexie": "^3.0.1",
    "electron": "9.0.3",
    "menubar": "^9.0.1",
    "request": "^2.88.2"
  },
  "devDependencies": {
    "mocha": "^8.0.1",
    "spectron": "^11.0.0"
  }
}

test/spec.js

const assert = require('assert'); //引入Nodde 內置的斷言庫
const path = require('path'); //引入Nodde 文集路徑輔助工具
const Application = require('spectron').Application; //引入Spectron的應用驅動程序
const electronPath = require('electron'); //已入electron,這讓我們可以訪問本地的Electron的開發版本

const app = new Application({
    path: electronPath,  //創建Spectron的Application對象,告訴他使用本地的Electron開發版本
    // The following line tells spectron to look and use the main.js file
    // and the package.json located 1 level above.
    //應用自身的根目錄作為應用的起始點和當前項目package.json文件路徑
    args: [path.join(__dirname, '..')]
});

//mocha:定義一組測試
describe('Clipmaster 9000', function () {
    this.timeout(10000); //由於應用程序需要花費一些時間,因此增加Mocha的默認超時時間

    beforeEach(() => {
        return app.start(); //在每個應用之前啟動應用
    });

    afterEach(() => {
        if (app && app.isRunning()) {
            return app.stop();  //結束每個測試后停止應用
        }
    });

    //定義一個測試(一個測試用例)
    it('啟動一個窗口', async function () {
        let count = await app.client.getWindowCount();
        return assert.equal(count, 1);
    })

    it('窗口標題是否正確', async () => {
        //waitUntilWindowLoaded等待窗口加載完html、css、js后獲取標題
        let title = await app.client.waitUntilWindowLoaded().getTitle();
        return assert.equal(title, 'Clipmaster 9000');
    })

    it('不要打開開發者工具', async () => {
        let devToolsAreOpen = await app.client
            .waitUntilWindowLoaded()
            .browserWindow.isDevToolsOpened();
        return assert.equal(devToolsAreOpen, false);
    })

    it('有一個按鈕,其文本為"Copy from Clipbard"', async () => {
        //getText()是WebdriveIO提供,返回一個節點的文本內容的Promise對象
        let buttonText = await app.client.getText("#copy-from-clipboard");

        return assert.equal(buttonText, "Copy from Clipboard");
    })

    it('應用啟動是不存在剪貼項', async () => {
        await app.client.waitUntilWindowLoaded();
        let clippings = await app.client.$$('.clippings-list-item'); //document.querySelectorAll
        return assert.equal(clippings.length, 0);
    })

    it('點擊"copy-from-clipboard"按鈕時,增加一項剪貼項', async () => {
        await app.client.waitUntilWindowLoaded();
        await app.client.click("#copy-from-clipboard");
        let clippings = await app.client.$$('.clippings-list-item');
        return assert.equal(clippings.length, 1);
    })

    it('可以刪除剪切項', async () => {
        await app.client.waitUntilWindowLoaded();
        await app.client.click("#copy-from-clipboard") //先添加一項
            .moveToObject(".clippings-list-item")//默認Remove按鈕是隱藏的,不能點擊,故先將鼠標指針移動到DOM元素上
            .click(".remove-clipping");//刪除
        let clippings = await app.client.$$('.clippings-list-item');
        return assert.equal(clippings.length, 0);
    })

    it('從剪貼板復制並顯示正確的文本', async () => {
        let testText = 'Hello Word';
        await app.client.waitUntilWindowLoaded();
        await app.electron.clipboard.writeText(testText);
        await app.client.click("#copy-from-clipboard");
        let clippingText = await app.client.getText('.clipping-text');

        return assert.equal(clippingText, testText);
    })

    it('寫剪貼板是否正確', async () => {
        //從剪貼板復制文本
        let testText = 'Hello Word';
        await app.client.waitUntilWindowLoaded();
        await app.electron.clipboard.writeText(testText);
        await app.client.click("#copy-from-clipboard");

        //模擬剪貼板有新的內容
        await app.electron.clipboard.writeText('剪貼板新內容');

        //寫剪貼板
        await app.client.click('.copy-clipping');
        let clippingText = await app.electron.clipboard.readText();

        return assert.equal(clippingText, testText);
    })
});

main.js

有兩個版本:有系統托盤版本和無系統托盤的版本

有系統托盤版本

const { menubar } = require('menubar');
const { globalShortcut, Menu } = require('electron');

const mb = menubar({
  preloadWindow: true,
  browserWindow: {
    webPreferences: {
      nodeIntegration: true
    },

  },
  index: `file://${__dirname}/index.html`,
});


mb.on('ready', () => {
  const secondaryMenu = Menu.buildFromTemplate([
    {
      label: 'Quit',
      click() { mb.app.quit(); },
      accelerator: 'CommandOrControl+Q'
    },
  ]);

  mb.tray.on('right-click', () => {
    mb.tray.popUpContextMenu(secondaryMenu);
  });

  const createClipping = globalShortcut.register('CommandOrControl+!', () => {
    mb.window.webContents.send('create-new-clipping');
  });

  const writeClipping = globalShortcut.register('CmdOrCtrl+Alt+@', () => {
    mb.window.webContents.send('write-to-clipboard');
  });

  const publishClipping = globalShortcut.register('CmdOrCtrl+Alt+#', () => {
    mb.window.webContents.send('publish-clipping');
  });

  if (!createClipping) { console.error('Registration failed', 'createClipping'); }
  if (!writeClipping) { console.error('Registration failed', 'writeClipping'); }
  if (!publishClipping) { console.error('Registration failed', 'publishClipping'); }

});

無系統托盤版本

const { app, BrowserWindow, globalShortcut, Menu } = require('electron');
let mainWindow = null; // #A

app.on('ready', () => {
  mainWindow = createWindow();

  const createClipping = globalShortcut.register('CommandOrControl+!', () => {
    mainWindow.webContents.send('create-new-clipping');
  });

  const writeClipping = globalShortcut.register('CmdOrCtrl+Alt+@', () => {
    mainWindow.webContents.send('write-to-clipboard');
  });

  const publishClipping = globalShortcut.register('CmdOrCtrl+Alt+#', () => {
    mainWindow.webContents.send('publish-clipping');
  });

  if (!createClipping) { console.error('Registration failed', 'createClipping'); }
  if (!writeClipping) { console.error('Registration failed', 'writeClipping'); }
  if (!publishClipping) { console.error('Registration failed', 'publishClipping'); }

});

const createWindow = () => {
  let newWindow = new BrowserWindow({
    show: false, //#A.1首次創建窗口,先隱藏
    webPreferences: {
      nodeIntegration: true
    }
  });

  //#A.2 需要長時間加載的頁面
  newWindow.loadURL(`${__dirname}/index.html`); // #A

  //#A.3:一次性時間監聽器,DOM就緒后再顯示窗口,避免在窗口中顯示白屏
  newWindow.once('ready-to-show', () => {
    newWindow.show();
    //mainWindow.webContents.openDevTools();
  });

  newWindow.on('closed', () => {
    newWindow = null;
  })

  return newWindow;
}

renderer.js

const { clipboard, ipcRenderer, shell } = require('electron');
const db = require('./dbdexie');

let baseUrl = 'https://api.github.com/gists';
const request = require('request').defaults({
  url: baseUrl,
  headers: { 'User-Agent': 'Clipmaster 9000' }
});

const clippingsList = document.getElementById('clippings-list');
const copyFromClipboardButton = document.getElementById('copy-from-clipboard');

ipcRenderer.on('create-new-clipping', () => {
  addClippingToList();
  new Notification('Clipping Added', {
    body: `${clipboard.readText()}`
  });
});

ipcRenderer.on('write-to-clipboard', () => {
  const clipping = clippingsList.firstChild;
  writeToClipboard(getClippingText(clipping));
  new Notification('Clipping Copied', {
    body: `${clipboard.readText()}`
  });
});

ipcRenderer.on('publish-clipping', () => {
  const clipping = clippingsList.firstChild;
  publishClipping(getClippingText(clipping));
});

const initClippingElement = () => {
  /*
  db.clips
    //.reverse() //按主鍵降序排列
    .each(clip => {
      let clippingElement = createClippingElement(clip.id, clip.value);
      clippingsList.prepend(clippingElement);
    });
*/
  db.clips
    .orderBy('id')
    //.reverse()  //按主鍵降序排列
    .toArray((clips) => {
      for (var clip of clips) {
        let clippingElement = createClippingElement(clip.id, clip.value);
        clippingsList.prepend(clippingElement);
      }
    });
}

const createClippingElement = (clipId, clippingText) => {
  const clippingElement = document.createElement('article');

  clippingElement.classList.add('clippings-list-item');

  clippingElement.innerHTML = `
    <div class="clipping-text" disabled="true"></div>
    <div class="clipping-controls">
      <button class="copy-clipping">&rarr; Clipboard</button>
      <button class="publish-clipping">Publish</button>
      <button class="remove-clipping" data-clipId='${clipId}'>Remove</button>
    </div>
  `;

  clippingElement.querySelector('.clipping-text').innerText = clippingText;

  return clippingElement;
};

const addClippingToList = () => {
  const clippingText = clipboard.readText();
  db.clips.add({ value: clippingText, data: Date.now() })
    .then(id => {
      return db.clips.get(id);
    }).then(item => {
      const clippingElement = createClippingElement(item.id, item.value);
      clippingsList.prepend(clippingElement);
    }).catch(err => {
      alert("Error: " + (err.stack || err));
    });
};

copyFromClipboardButton.addEventListener('click', addClippingToList);

clippingsList.addEventListener('click', (event) => {
  const hasClass = className => event.target.classList.contains(className);
  let clipId = parseInt(event.target.getAttribute("data-clipId"));
  const clippingListItem = getButtonParent(event);

  if (hasClass('remove-clipping')) removeClipping(clipId, clippingListItem);
  if (hasClass('copy-clipping')) writeToClipboard(getClippingText(clippingListItem));
  if (hasClass('publish-clipping')) publishClipping(getClippingText(clippingListItem));
});

const removeClipping = (clipId, target) => {
  db.clips.delete(clipId).then(result => {
    //Promise that resolves successfully with an undefined result, no matter if a record was deleted or not. 
    //不管刪沒刪,都是返回:undefined
    target.remove();
  });
};

const writeToClipboard = (clippingText) => {
  clipboard.writeText(clippingText);
};

const publishClipping = (clippingText) => {

  request.post(baseUrl, toJSON(clippingText), (err, response, body) => {

    if (err) {
      return new Notification('Error Publishing Your Clipping', {
        body: JSON.parse(err).message
      });
    }

    const gistUrl = JSON.parse(body).documentation_url; //   const gistUrl = JSON.parse(body).html_url;
    const notification = new Notification('Your Clipping Has Been Published', {
      body: `Click to open ${gistUrl} in your browser.`
    });

    notification.onclick = () => { shell.openExternal(gistUrl); };

    clipboard.writeText(gistUrl);
  });

};

const getButtonParent = ({ target }) => {
  return target.parentNode.parentNode;
};

const getClippingText = (clippingListItem) => {
  return clippingListItem.querySelector('.clipping-text').innerText;
};

const toJSON = (clippingText) => {
  return {
    body: JSON.stringify({
      description: 'Created with Clipmaster 9000',
      public: 'true',
      files: {
        'clipping.txt': { content: clippingText }
      }
    })
  };
};

initClippingElement();

index.html

<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Clipmaster 9000</title>
  <link rel="stylesheet" href="style.css" type="text/css">
</head>

<body>
  <div class="container">
    <section class="controls">
      <button id="copy-from-clipboard">Copy from Clipboard</button>
    </section>

    <section class="content">
      <div id="clippings-list" class="clippings-list"></div>
    </section>
  </div>
  <script>
    require('./renderer.js');
  </script>
</body>

</html>

style.css

html {
  box-sizing: border-box;
}

html, body {
  height: 100%;
  width: 100%;
  overflow: hidden;
  font-size: 12px;
}

*, *:before, *:after {
  box-sizing: inherit;
}

body {
  margin: 0;
  padding: 0;
}

body, input {
  font: menu;
  font-size: 12px;
}

body > div {
  height: 100%;
  overflow: scroll;
  -webkit-overflow-scrolling: touch;
}

.container {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  overflow: auto;
}

textarea, input, div, button { outline: none; }

.controls {
  background-color: rgb(217, 241, 238);
  padding: 1em;
  top: 0;
  position: fixed;
  width: 100%;
}

.controls button {
  background-color: rgb(181, 220, 216);
  border: none;
  padding: 0.5em 1em;
}

.controls button:hover {
  background-color: rgb(156, 198, 192);
}

.controls button:active {
  background-color: rgb(144, 182, 177);
}

.controls button:disabled {
  background-color: rgb(196, 204, 202);
}

.content {
  height: 100%;
}

.clippings-list {
  margin-top: 65px;
  padding: 0 10px;
}

.clippings-list-item {
  border: 1px solid rgb(178, 193, 191);
  box-shadow: 1px 1px 1px 1px rgba(205, 228, 224, 0.78);
  padding: 0.5em;
  margin-bottom: 1em;
}

.clipping-text {
  background-color: rgb(228, 248, 245);
  padding: 0.5em;
  min-width: 100%;
  max-height: 10em;
  overflow: scroll;
}

.clipping-text::-webkit-scrollbar {
  display: none;
}

.clipping-controls {
  margin-top: 0.5em;
}

button {
  background-color: rgb(181, 220, 216);
  border: none;
  font-size: 0.8em;
  padding: 0.5em 1em;
}

button:hover {
  background-color: rgb(156, 198, 192);
}

button:active {
  background-color: rgb(144, 182, 177);
}

button:disabled {
  background-color: rgb(196, 204, 202);
}

button.remove-clipping {
  display: none;
  float: right;
  color: white;
  background-color: rgb(208, 69, 55);
}

button.remove-clipping:hover {
  background-color: rgb(208, 41, 29);
}

button.remove-clipping:active {
  background-color: rgb(236, 0, 6);
}

button.remove-clipping:disabled {
  background-color: rgb(152, 73, 64);
}

.clippings-list-item:hover button.remove-clipping {
  display: inline-block;
}

dbdexie.js

const Dexie = require('dexie');
const db = new Dexie('clipsmarter_database');

db.version(1).stores({
    clips: '++id, value, data'
});

module.exports = db;


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM