TestDriven-io-博客中文翻译-五-
TestDriven.io 博客中文翻译(五)
并行运行 Cypress 测试
原文:https://testdriven.io/blog/running-cypress-tests-in-parallel/
在接下来的教程中,我们将带您了解如何配置 Cypress 来与 CircleCI 并行运行测试。
想看看最终项目的运行情况吗?查看视频。
项目设置
让我们从建立一个基本的 Cypress 项目开始:
`$ mkdir cypress-parallel && cd cypress-parallel
$ npm init -y
$ npm install cypress --save-dev
$ ./node_modules/.bin/cypress open`
这将创建一个新的项目文件夹,添加一个 package.json 文件,安装 Cypress,打开 Cypress GUI,并构建出以下文件和文件夹:
`├── cypress
│ ├── fixtures
│ │ └── example.json
│ ├── integration
│ │ └── examples
│ │ ├── actions.spec.js
│ │ ├── aliasing.spec.js
│ │ ├── assertions.spec.js
│ │ ├── connectors.spec.js
│ │ ├── cookies.spec.js
│ │ ├── cypress_api.spec.js
│ │ ├── files.spec.js
│ │ ├── local_storage.spec.js
│ │ ├── location.spec.js
│ │ ├── misc.spec.js
│ │ ├── navigation.spec.js
│ │ ├── network_requests.spec.js
│ │ ├── querying.spec.js
│ │ ├── spies_stubs_clocks.spec.js
│ │ ├── traversal.spec.js
│ │ ├── utilities.spec.js
│ │ ├── viewport.spec.js
│ │ ├── waiting.spec.js
│ │ └── window.spec.js
│ ├── plugins
│ │ └── index.js
│ └── support
│ ├── commands.js
│ └── index.js
└─── cypress.json`
关上柏圭。然后,删除“cypress/integration/examples”文件夹,并添加四个样本规范文件:
sample1.spec.js
`describe('Cypress parallel run example - 1', () => { it('should display the title', () => { cy.visit(`https://mherman.org`); cy.get('a').contains('Michael Herman'); }); });`
sample2.spec.js
`describe('Cypress parallel run example - 2', () => { it('should display the blog link', () => { cy.visit(`https://mherman.org`); cy.get('a').contains('Blog'); }); });`
sample3.spec.js
`describe('Cypress parallel run example - 3', () => { it('should display the about link', () => { cy.visit(`https://mherman.org`); cy.get('a').contains('About'); }); });`
sample4.spec.js
`describe('Cypress parallel run example - 4', () => { it('should display the rss link', () => { cy.visit(`https://mherman.org`); cy.get('a').contains('RSS'); }); });`
您的项目现在应该具有以下结构:
`├── cypress
│ ├── fixtures
│ │ └── example.json
│ ├── integration
│ │ ├── sample1.spec.js
│ │ ├── sample2.spec.js
│ │ ├── sample3.spec.js
│ │ └── sample4.spec.js
│ ├── plugins
│ │ └── index.js
│ └── support
│ ├── commands.js
│ └── index.js
├── cypress.json
├── package-lock.json
└── package.json`
确保在继续之前通过测试:
`$ ./node_modules/.bin/cypress run
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ sample1.spec.js 00:02 1 1 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ sample2.spec.js 00:01 1 1 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ sample3.spec.js 00:02 1 1 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ sample4.spec.js 00:01 1 1 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
All specs passed! 00:08 4 4 - - -`
一旦完成,添加一个。gitignore 文件:
`node_modules/
cypress/videos/
cypress/screenshots/`
在 GitHub 上创建一个名为 cypress-parallel 的新存储库,在本地初始化一个新的 git repo,然后将代码提交到 GitHub。
CircleCI 设置
如果你还没有一个 CircleCI 账户,就注册吧。然后,在 CircleCI 上添加柏树平行作为新项目。
查看入门指南,了解如何在 CircleCI 上设置和使用项目。
向名为“”的文件夹中添加一个新文件。circleci”,然后向该文件夹添加一个名为 config.yml 的新文件:
`version: 2 jobs: build: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - checkout - run: pwd - run: ls - restore_cache: keys: - 'v2-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}' - 'v2-deps-{{ .Branch }}-' - v2-deps- - run: npm ci - save_cache: key: 'v2-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}' paths: - ~/.npm - ~/.cache - persist_to_workspace: root: ~/ paths: - .cache - tmp test: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: ~/ - run: ls -la cypress - run: ls -la cypress/integration - run: name: Running cypress tests command: $(npm bin)/cypress run - store_artifacts: path: cypress/videos - store_artifacts: path: cypress/screenshots workflows: version: 2 build_and_test: jobs: - build - test: requires: - build`
这里,我们配置了两个作业,build
和test
。build
作业安装 Cypress,测试在test
作业中运行。这两个任务都在 Docker 内部运行,并从 cypress/base 映像扩展而来。
有关 CircleCI 配置的更多信息,请查看配置介绍指南。
提交并推送您的代码以触发新的构建。确保两个工作都通过。您应该能够在test
任务的“工件”选项卡中看到 Cypress 录制的视频:
这样,让我们看看如何使用配置文件分割测试,这样 Cypress 测试可以在并行中运行。
平行
我们将从手动拆分它们开始。像这样更新配置文件:
`version: 2 jobs: build: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - checkout - run: pwd - run: ls - restore_cache: keys: - 'v2-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}' - 'v2-deps-{{ .Branch }}-' - v2-deps- - run: npm ci - save_cache: key: 'v2-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}' paths: - ~/.npm - ~/.cache - persist_to_workspace: root: ~/ paths: - .cache - tmp test1: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: ~/ - run: ls -la cypress - run: ls -la cypress/integration - run: name: Running cypress tests 1 command: $(npm bin)/cypress run --spec cypress/integration/sample1.spec.js - store_artifacts: path: cypress/videos - store_artifacts: path: cypress/screenshots test2: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: ~/ - run: ls -la cypress - run: ls -la cypress/integration - run: name: Running cypress tests 2 command: $(npm bin)/cypress run --spec cypress/integration/sample2.spec.js - store_artifacts: path: cypress/videos - store_artifacts: path: cypress/screenshots test3: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: ~/ - run: ls -la cypress - run: ls -la cypress/integration - run: name: Running cypress tests 3 command: $(npm bin)/cypress run --spec cypress/integration/sample3.spec.js - store_artifacts: path: cypress/videos - store_artifacts: path: cypress/screenshots test4: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: ~/ - run: ls -la cypress - run: ls -la cypress/integration - run: name: Running cypress tests 4 command: $(npm bin)/cypress run --spec cypress/integration/sample4.spec.js - store_artifacts: path: cypress/videos - store_artifacts: path: cypress/screenshots workflows: version: 2 build_and_test: jobs: - build - test1: requires: - build - test2: requires: - build - test3: requires: - build - test4: requires: - build`
因此,我们创建了四个测试作业,每个作业将在 CircleCI 上的不同机器上运行单个 spec 文件。提交你的代码并上传到 GitHub。这一次,一旦build
作业完成,您应该会看到每个测试作业同时运行:
接下来,让我们看看如何动态生成配置文件。
生成 CircleCI 配置
在项目根目录中创建一个“lib”文件夹,然后将以下文件添加到该文件夹中:
- circle.json
- generate-circle-config . js
将build
作业的配置添加到 circle.json :
`{ "version": 2, "jobs": { "build": { "working_directory": "~/tmp", "docker": [ { "image": "cypress/base:10", "environment": { "TERM": "xterm" } } ], "steps": [ "checkout", { "run": "pwd" }, { "run": "ls" }, { "restore_cache": { "keys": [ "v2-deps-{{ .Branch }}-{{ checksum \"package-lock.json\" }}", "v2-deps-{{ .Branch }}-", "v2-deps-" ] } }, { "run": "npm ci" }, { "save_cache": { "key": "v2-deps-{{ .Branch }}-{{ checksum \"package-lock.json\" }}", "paths": [ "~/.npm", "~/.cache" ] } }, { "persist_to_workspace": { "root": "~/", "paths": [ ".cache", "tmp" ] } } ] } }, "workflows": { "version": 2, "build_and_test": { "jobs": [ "build" ] } } }`
本质上,我们将使用这个配置作为基础,动态地向其中添加测试作业,然后在 YAML 保存最终的配置文件。
将代码添加到generate-circle-config . js中:
- 从“cypress/integration”目录中获取等级库文件的名称
- 将 circle.json 文件作为对象读取
- 将测试作业添加到对象
- 将对象转换为 YAML,并作为写入磁盘。circleci/config.yml
代码:
`const path = require('path'); const fs = require('fs'); const yaml = require('write-yaml'); /*
helpers
*/ function createJSON(fileArray, data) { for (const [index, value] of fileArray.entries()) { data.jobs[`test${index + 1}`] = { working_directory: '~/tmp', docker: [ { image: 'cypress/base:10', environment: { TERM: 'xterm', }, }, ], steps: [ { attach_workspace: { at: '~/', }, }, { run: 'ls -la cypress', }, { run: 'ls -la cypress/integration', }, { run: { name: `Running cypress tests ${index + 1}`, command: `$(npm bin)/cypress run --spec cypress/integration/${value}`, }, }, { store_artifacts: { path: 'cypress/videos', }, }, { store_artifacts: { path: 'cypress/screenshots', }, }, ], }; data.workflows.build_and_test.jobs.push({ [`test${index + 1}`]: { requires: [ 'build', ], }, }); } return data; } function writeFile(data) { yaml(path.join(__dirname, '..', '.circleci', 'config.yml'), data, (err) => { if (err) { console.log(err); } else { console.log('Success!'); } }); } /*
main
*/ // get spec files as an array const files = fs.readdirSync(path.join(__dirname, '..', 'cypress', 'integration')).filter(fn => fn.endsWith('.spec.js')); // read circle.json const circleConfigJSON = require(path.join(__dirname, 'circle.json')); // add cypress specs to object as test jobs const data = createJSON(files, circleConfigJSON); // write file to disc writeFile(data);`
自己回顾(并重构)这一点。
安装 write-yaml 然后生成新的配置文件:
`$ npm install write-yaml --save-dev
$ node lib/generate-circle-config.js`
再次提交您的代码,并将其推送到 GitHub 以触发新的构建。同样,四个测试任务应该在build
任务完成后并行运行。
摩卡真棒
接下来,让我们添加 mochawesome 作为 Cypress 自定义报告器,这样我们就可以在所有测试任务运行完毕后生成一个漂亮的报告。
安装:
`$ npm install mochawesome mocha --save-dev`
更新generate-circle-config . js中createJSON
函数的以下run
步骤:
`run: { name: `Running cypress tests ${index + 1}`, command: `$(npm bin)/cypress run --spec cypress/integration/${value} --reporter mochawesome --reporter-options "reportFilename=test${index + 1}"`, },`
然后,添加一个新步骤,将生成的报告作为一个工件存储到createJSON
:
`{ store_artifacts: { path: 'mochawesome-report', }, },`
createJSON
现在应该是这样的:
`function createJSON(fileArray, data) { for (const [index, value] of fileArray.entries()) { data.jobs[`test${index + 1}`] = { working_directory: '~/tmp', docker: [ { image: 'cypress/base:10', environment: { TERM: 'xterm', }, }, ], steps: [ { attach_workspace: { at: '~/', }, }, { run: 'ls -la cypress', }, { run: 'ls -la cypress/integration', }, { run: { name: `Running cypress tests ${index + 1}`, command: `$(npm bin)/cypress run --spec cypress/integration/${value} --reporter mochawesome --reporter-options "reportFilename=test${index + 1}"`, }, }, { store_artifacts: { path: 'cypress/videos', }, }, { store_artifacts: { path: 'cypress/screenshots', }, }, { store_artifacts: { path: 'mochawesome-report', }, }, ], }; data.workflows.build_and_test.jobs.push({ [`test${index + 1}`]: { requires: [ 'build', ], }, }); } return data; }`
现在,每个测试运行将生成一个具有唯一名称的 mochawesome 报告。试试看。生成新的配置。提交并推送您的代码。每个测试作业都应该在“工件”选项卡中存储一份生成的 mochawesome 报告的副本:
实际的报告应该是这样的:
合并报告
下一步是将单独的报告合并成一个报告。首先向createJSON
函数添加一个新步骤,将生成的报告存储在工作区中:
`{ persist_to_workspace: { root: 'mochawesome-report', paths: [ `test${index + 1}.json`, `test${index + 1}.html`, ], }, },`
此外,向 lib/circle.json 添加一个名为combine_reports
的新作业,它附加工作区,然后运行一个ls
命令来显示目录的内容:
`"combine_reports": { "working_directory": "~/tmp", "docker": [ { "image": "cypress/base:10", "environment": { "TERM": "xterm" } } ], "steps": [ { "attach_workspace": { "at": "/tmp/mochawesome-report" } }, { "run": "ls /tmp/mochawesome-report" } ] }`
ls
的目的是确保我们正确地持久化和附加工作空间。换句话说,在运行时,您应该看到“/tmp/mochawesome-report”目录中的所有报告。
因为这个作业依赖于测试作业,所以再次更新createJSON
,就像这样:
`function createJSON(fileArray, data) { const jobs = []; for (const [index, value] of fileArray.entries()) { jobs.push(`test${index + 1}`); data.jobs[`test${index + 1}`] = { working_directory: '~/tmp', docker: [ { image: 'cypress/base:10', environment: { TERM: 'xterm', }, }, ], steps: [ { attach_workspace: { at: '~/', }, }, { run: 'ls -la cypress', }, { run: 'ls -la cypress/integration', }, { run: { name: `Running cypress tests ${index + 1}`, command: `$(npm bin)/cypress run --spec cypress/integration/${value} --reporter mochawesome --reporter-options "reportFilename=test${index + 1}"`, }, }, { store_artifacts: { path: 'cypress/videos', }, }, { store_artifacts: { path: 'cypress/screenshots', }, }, { store_artifacts: { path: 'mochawesome-report', }, }, { persist_to_workspace: { root: 'mochawesome-report', paths: [ `test${index + 1}.json`, `test${index + 1}.html`, ], }, }, ], }; data.workflows.build_and_test.jobs.push({ [`test${index + 1}`]: { requires: [ 'build', ], }, }); } data.workflows.build_and_test.jobs.push({ combine_reports: { 'requires': jobs, }, }); return data; }`
生成配置:
`$ node lib/generate-circle-config.js`
配置文件现在应该看起来像这样:
`version: 2 jobs: build: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - checkout - run: pwd - run: ls - restore_cache: keys: - 'v2-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}' - 'v2-deps-{{ .Branch }}-' - v2-deps- - run: npm ci - save_cache: key: 'v2-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}' paths: - ~/.npm - ~/.cache - persist_to_workspace: root: ~/ paths: - .cache - tmp combine_reports: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: /tmp/mochawesome-report - run: ls /tmp/mochawesome-report test1: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: ~/ - run: ls -la cypress - run: ls -la cypress/integration - run: name: Running cypress tests 1 command: >- $(npm bin)/cypress run --spec cypress/integration/sample1.spec.js --reporter mochawesome --reporter-options "reportFilename=test1" - store_artifacts: path: cypress/videos - store_artifacts: path: cypress/screenshots - store_artifacts: path: mochawesome-report - persist_to_workspace: root: mochawesome-report paths: - test1.json - test1.html test2: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: ~/ - run: ls -la cypress - run: ls -la cypress/integration - run: name: Running cypress tests 2 command: >- $(npm bin)/cypress run --spec cypress/integration/sample2.spec.js --reporter mochawesome --reporter-options "reportFilename=test2" - store_artifacts: path: cypress/videos - store_artifacts: path: cypress/screenshots - store_artifacts: path: mochawesome-report - persist_to_workspace: root: mochawesome-report paths: - test2.json - test2.html test3: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: ~/ - run: ls -la cypress - run: ls -la cypress/integration - run: name: Running cypress tests 3 command: >- $(npm bin)/cypress run --spec cypress/integration/sample3.spec.js --reporter mochawesome --reporter-options "reportFilename=test3" - store_artifacts: path: cypress/videos - store_artifacts: path: cypress/screenshots - store_artifacts: path: mochawesome-report - persist_to_workspace: root: mochawesome-report paths: - test3.json - test3.html test4: working_directory: ~/tmp docker: - image: 'cypress/base:10' environment: TERM: xterm steps: - attach_workspace: at: ~/ - run: ls -la cypress - run: ls -la cypress/integration - run: name: Running cypress tests 4 command: >- $(npm bin)/cypress run --spec cypress/integration/sample4.spec.js --reporter mochawesome --reporter-options "reportFilename=test4" - store_artifacts: path: cypress/videos - store_artifacts: path: cypress/screenshots - store_artifacts: path: mochawesome-report - persist_to_workspace: root: mochawesome-report paths: - test4.json - test4.html workflows: version: 2 build_and_test: jobs: - build - test1: requires: - build - test2: requires: - build - test3: requires: - build - test4: requires: - build - combine_reports: requires: - test1 - test2 - test3 - test4`
再次提交并推送到 GitHub。确保combine_reports
运行到最后:
接下来,添加一个脚本来合并报告:
`const fs = require('fs'); const path = require('path'); const shell = require('shelljs'); const uuidv1 = require('uuid/v1'); function getFiles(dir, ext, fileList = []) { const files = fs.readdirSync(dir); files.forEach((file) => { const filePath = `${dir}/${file}`; if (fs.statSync(filePath).isDirectory()) { getFiles(filePath, fileList); } else if (path.extname(file) === ext) { fileList.push(filePath); } }); return fileList; } function traverseAndModifyTimedOut(target, deep) { if (target['tests'] && target['tests'].length) { target['tests'].forEach(test => { test.timedOut = false; }); } if (target['suites']) { target['suites'].forEach(suite => { traverseAndModifyTimedOut(suite, deep + 1); }) } } function combineMochaAwesomeReports() { const reportDir = path.join('/', 'tmp', 'mochawesome-report'); const reports = getFiles(reportDir, '.json', []); const suites = []; let totalSuites = 0; let totalTests = 0; let totalPasses = 0; let totalFailures = 0; let totalPending = 0; let startTime; let endTime; let totalskipped = 0; reports.forEach((report, idx) => { const rawdata = fs.readFileSync(report); const parsedData = JSON.parse(rawdata); if (idx === 0) { startTime = parsedData.stats.start; } if (idx === (reports.length - 1)) { endTime = parsedData.stats.end; } totalSuites += parseInt(parsedData.stats.suites, 10); totalskipped += parseInt(parsedData.stats.skipped, 10); totalPasses += parseInt(parsedData.stats.passes, 10); totalFailures += parseInt(parsedData.stats.failures, 10); totalPending += parseInt(parsedData.stats.pending, 10); totalTests += parseInt(parsedData.stats.tests, 10); if (parsedData && parsedData.suites && parsedData.suites.suites) { parsedData.suites.suites.forEach(suite => { suites.push(suite) }) } }); return { totalSuites, totalTests, totalPasses, totalFailures, totalPending, startTime, endTime, totalskipped, suites, }; } function getPercentClass(pct) { if (pct <= 50) { return 'danger'; } else if (pct > 50 && pct < 80) { return 'warning'; } return 'success'; } function writeReport(obj, uuid) { const sampleFile = path.join(__dirname, 'sample.json'); const outFile = path.join(__dirname, '..', `${uuid}.json`); fs.readFile(sampleFile, 'utf8', (err, data) => { if (err) throw err; const parsedSampleFile = JSON.parse(data); const stats = parsedSampleFile.stats; stats.suites = obj.totalSuites; stats.tests = obj.totalTests; stats.passes = obj.totalPasses; stats.failures = obj.totalFailures; stats.pending = obj.totalPending; stats.start = obj.startTime; stats.end = obj.endTime; stats.duration = new Date(obj.endTime) - new Date(obj.startTime); stats.testsRegistered = obj.totalTests - obj.totalPending; stats.passPercent = Math.round((stats.passes / (stats.tests - stats.pending)) * 1000) / 10; stats.pendingPercent = Math.round((stats.pending / stats.testsRegistered) * 1000) /10; stats.skipped = obj.totalskipped; stats.hasSkipped = obj.totalskipped > 0; stats.passPercentClass = getPercentClass(stats.passPercent); stats.pendingPercentClass = getPercentClass(stats.pendingPercent); obj.suites.forEach(suit => { traverseAndModifyTimedOut(suit, 0); }); parsedSampleFile.suites.suites = obj.suites; parsedSampleFile.suites.uuid = uuid; fs.writeFile(outFile, JSON.stringify(parsedSampleFile), { flag: 'wx' }, (error) => { if (error) throw error; }); }); } const data = combineMochaAwesomeReports(); const uuid = uuidv1(); writeReport(data, uuid); shell.exec(`./node_modules/.bin/marge ${uuid}.json --reportDir mochareports --reportTitle ${uuid}`, (code, stdout, stderr) => { if (stderr) { console.log(stderr); } else { console.log('Success!'); } });`
将此保存为“lib”中的 combine.js 。
这个脚本将收集所有的 mochawesome JSON 文件(包含每个 mochawesome 报告的原始 JSON 输出),合并它们,并生成一个新的 mochawesome 报告。
如果感兴趣的话,可以返回到 CircleCI,在一个测试作业的“Artifacts”选项卡中查看一个生成的 mochawesome JSON 文件。
安装依赖项:
`$ npm install shelljs uuid --save-dev`
将 sample.json 添加到“lib”目录中:
`{ "stats": { "suites": 0, "tests": 0, "passes": 0, "pending": 0, "failures": 0, "start": "", "end": "", "duration": 0, "testsRegistered": 0, "passPercent": 0, "pendingPercent": 0, "other": 0, "hasOther": false, "skipped": 0, "hasSkipped": false, "passPercentClass": "success", "pendingPercentClass": "success" }, "suites": { "uuid": "", "title": "", "fullFile": "", "file": "", "beforeHooks": [], "afterHooks": [], "tests": [], "suites": [], "passes": [], "failures": [], "pending": [], "skipped": [], "duration": 0, "root": true, "rootEmpty": true, "_timeout": 2000 }, "copyrightYear": 2019 }`
更新 circle.json 中的combine_reports
以运行 combine.js 脚本,然后将新报告保存为工件:
`"combine_reports": { "working_directory": "~/tmp", "docker": [ { "image": "cypress/base:10", "environment": { "TERM": "xterm" } } ], "steps": [ "checkout", { "attach_workspace": { "at": "~/" } }, { "attach_workspace": { "at": "/tmp/mochawesome-report" } }, { "run": "ls /tmp/mochawesome-report" }, { "run": "node ./lib/combine.js" }, { "store_artifacts": { "path": "mochareports" } } ] }`
为了测试,生成新的配置,提交并推送您的代码。所有作业都应该通过,您应该会看到合并的最终报告。
处理测试失败
如果测试失败会发生什么?
将 sample2.spec.js 中的cy.get('a').contains('Blog');
改为cy.get('a').contains('Not Real');
:
`describe('Cypress parallel run example - 2', () => { it('should display the blog link', () => { cy.visit(`https://mherman.org`); cy.get('a').contains('Not Real'); }); });`
提交并推送您的代码。由于combine_reports
任务依赖于测试任务,如果其中任何一个测试任务失败,它就不会运行。
那么,即使工作流中的前一个作业失败,如何让combine_reports
作业运行呢?
不幸的是,CircleCI 目前不支持这一功能。更多信息请参见本讨论。因为我们实际上只关心 mochawesome JSON 报告,所以您可以通过取消测试作业的退出代码来解决这个问题。测试作业仍然会运行并生成 mochawesome 报告——不管底层测试是通过还是失败,它们都会通过。
在createJSON
中再次更新以下run
:
`run: { name: `Running cypress tests ${index + 1}`, command: `if $(npm bin)/cypress run --spec cypress/integration/${value} --reporter mochawesome --reporter-options "reportFilename=test${index + 1}"; then echo 'pass'; else echo 'fail'; fi`, },`
单行 bash if/else 有点难读。请自行重构。
有用吗?生成新的配置文件,提交并推送您的代码。所有的测试工作都应该通过,最终的 mochawesome 报告应该显示失败的规范。
最后一件事:如果一个任务失败了,我们可能仍然会让整个构建失败。实现这个的最快方法是在 combine.js 中的shell.exec
回调中:
`shell.exec(`./node_modules/.bin/marge ${uuid}.json --reportDir mochareports --reportTitle ${uuid}`, (code, stdout, stderr) => { if (stderr) { console.log(stderr); } else { console.log('Success!'); if (data.totalFailures > 0) { process.exit(1); } else { process.exit(0); } } });`
测试一下。然后,尝试测试一些其他场景,比如跳过一个测试或者添加四个以上的规范文件。
结论
本教程着眼于如何在 CircleCI 上并行运行 Cypress 测试,而不使用 Cypress record 特性。值得注意的是,您可以使用任何提供并行性的 CI 服务来实现完全相同的工作流,例如 GitLab CI 、 Travis 和 Semaphore ,以及您自己的定制 CI 平台 Jenkins 或 Concourse 。如果您的 CI 服务不提供并行性,那么您可以使用 Docker 并行运行作业。请联系我们了解更多详情。
寻找一些挑战?
- 创建一个 Slack bot,在测试运行完成时通知一个频道,并添加一个链接到 mochawesome 报告以及任何失败测试规范的截图或视频
- 将最终报告上传到 S3 桶(见 cypress-mochawesome-s3 )
- 通过将测试结果存储在数据库中,跟踪一段时间内失败测试的数量
- 将整个测试套件作为一项夜间工作运行多次,然后只在测试失败 X 次时指出测试是否失败——这将有助于暴露不可靠的测试,并消除不必要的开发人员干预
从赛普拉斯平行的回购协议中获取最终代码。干杯!
在 Docker Swarm 上运行烧瓶
原文:https://testdriven.io/blog/running-flask-on-docker-swarm/
让我们看看如何在 DigitalOcean 上启动 Docker Swarm 集群,然后配置一个由 Flask 和 Postgres 提供支持的微服务,在其上运行。
这是一个中级教程。它假设你有 Flask、Docker 和容器编排的基本工作知识。有关这些工具和主题的更多信息,请查看以下课程:
Docker 依赖:
- 文档编号 v19.03.9
- 坞站-复合 v1.27.4
- 对接机 v0.16.2
目标
本教程结束时,您将能够...
- 解释什么是容器编排,以及为什么需要使用编排工具
- 讨论使用 Docker Swarm 优于其他编排工具(如 Kubernetes 和弹性容器服务(ECS ))的利弊
- 使用 Docker Compose 在本地构建基于 Flask 的微服务
- 构建 Docker 映像,并将它们上传到 Docker Hub 映像注册中心
- 使用 Docker 机器在 DigitalOcean 上配置主机
- 配置一个 Docker 群集群在数字海洋上运行
- 在 Docker Swarm 上运行 Flask、Nginx 和 Postgres
- 使用循环算法在群集上路由流量
- 用 Docker Swarm Visualizer 监控集群
- 使用 Docker Secrets 管理 Docker Swarm 中的敏感信息
- 配置运行状况检查,以便在将服务添加到群集之前检查其状态
- 访问运行在集群上的服务的日志
什么是容器编排?
当您从在单台机器上部署容器转移到在多台机器上部署容器时,您将需要一个编排工具来管理(并自动化)容器在整个系统中的安排、协调和可用性。
这就是 Docker Swarm (或“Swarm mode”)与许多其他编排工具相适应的地方——如 Kubernetes 、 ECS 、 Mesos 和 Nomad 。
你应该用哪一个?
- 如果您需要管理大型、复杂的集群,请使用 Kubernetes
- 如果您刚刚起步和/或需要管理中小型集群,请使用 Docker Swarm
- 如果您已经在使用一些 AWS 服务,请使用 ECS
工具 | 赞成的意见 | 骗局 |
---|---|---|
库伯内特斯 | 大型社区,灵活,大多数功能,时尚 | 复杂的设置、高学习曲线、hip |
码头工人群 | 易于设置,非常适合小型集群 | 受 Docker API 的限制 |
精英公司 | 全面管理的服务,与 AWS 集成 | 供应商锁定 |
市场上还有许多基于 Kubernetes 的托管服务:
- 谷歌 Kubernetes 引擎 (GKE)
- 弹性集装箱服务 (EKS)
- Azure Kubernetes 服务公司
更多信息,请查看选择正确的容器化和集群管理工具博文。
项目设置
从烧瓶-docker-swarm repo 中克隆出碱基分支;
`$ git clone https://github.com/testdrivenio/flask-docker-swarm --branch base --single-branch
$ cd flask-docker-swarm`
构建映像并在本地旋转容器:
`$ docker-compose up -d --build`
创建并植入数据库users
表:
`$ docker-compose run web python manage.py recreate_db
$ docker-compose run web python manage.py seed_db`
在您选择的浏览器中测试以下 URL。
`{ "container_id": "3c9dc22aa37a", "message": "pong!", "status": "success" }`
container_id
是运行应用程序的 Docker 容器的 ID:$ docker ps --filter name=flask-docker-swarm_web --format "{{.ID}}" 3c9dc22aa37a
`{ "container_id": "3c9dc22aa37a", "status": "success", "users": [{ "active": true, "admin": false, "email": "[[email protected]](/cdn-cgi/l/email-protection)", "id": 1, "username": "michael" }] }`
在继续之前,快速浏览一下代码:
`├── README.md
├── docker-compose.yml
└── services
├── db
│ ├── Dockerfile
│ └── create.sql
├── nginx
│ ├── Dockerfile
│ └── prod.conf
└── web
├── Dockerfile
├── manage.py
├── project
│ ├── __init__.py
│ ├── api
│ │ ├── main.py
│ │ ├── models.py
│ │ └── users.py
│ └── config.py
└── requirements.txt`
坞站集线器
由于 Docker Swarm 使用多个 Docker 引擎,我们需要使用一个 Docker 映像注册表来将我们的三个映像分发到每个引擎。本教程使用 Docker Hub image registry,但也可以随意使用不同的注册表服务或在 Swarm 中运行你自己的私有注册表。
在 Docker Hub 上创建一个帐户(如果您还没有),然后登录:
构建、标记和推送图像到 Docker Hub:
`$ docker build -t mjhea0/flask-docker-swarm_web:latest -f ./services/web/Dockerfile ./services/web
$ docker push mjhea0/flask-docker-swarm_web:latest
$ docker build -t mjhea0/flask-docker-swarm_db:latest -f ./services/db/Dockerfile ./services/db
$ docker push mjhea0/flask-docker-swarm_db:latest
$ docker build -t mjhea0/flask-docker-swarm_nginx:latest -f ./services/nginx/Dockerfile ./services/nginx
$ docker push mjhea0/flask-docker-swarm_nginx:latest`
确保用 Docker Hub 上的名称空间替换
mjhea0
。
撰写文件
接下来,让我们建立一个新的 Docker 组合文件,用于 Docker Swarm:
`version: '3.8' services: web: image: mjhea0/flask-docker-swarm_web:latest deploy: replicas: 1 restart_policy: condition: on-failure placement: constraints: [node.role == worker] expose: - 5000 environment: - FLASK_ENV=production - APP_SETTINGS=project.config.ProductionConfig - DB_USER=postgres - DB_PASSWORD=postgres - SECRET_CODE=myprecious depends_on: - db networks: - app db: image: mjhea0/flask-docker-swarm_db:latest deploy: replicas: 1 restart_policy: condition: on-failure placement: constraints: [node.role == manager] volumes: - data-volume:/var/lib/postgresql/data expose: - 5432 environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres networks: - app nginx: image: mjhea0/flask-docker-swarm_nginx:latest deploy: replicas: 1 restart_policy: condition: on-failure placement: constraints: [node.role == worker] ports: - 80:80 depends_on: - web networks: - app networks: app: driver: overlay volumes: data-volume: driver: local`
在项目根目录下将该文件另存为 docker-compose-swarm.yml 。请注意两个合成文件之间的差异:
- Image :我们现在使用一个图像来设置上下文,而不是引用本地构建目录。
- Deploy :我们增加了一个 deploy 关键字来配置每个服务的副本、重启策略和放置约束的数量。参考官方文档获取更多关于为 Docker Swarm 设置合成文件的信息。
- 网络:我们现在使用一个覆盖网络来连接每个主机上的多个 Docker 引擎,并实现群服务之间的通信。
码头工人群
注册一个数字海洋账户(如果你还没有的话),然后生成一个访问令牌,这样你就可以访问数字海洋 API 了。
将令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_digital_ocean_token]`
旋转四个数字海洋液滴:
`$ for i in 1 2 3 4; do
docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
node-$i;
done`
需要
--engine-install-url
,因为在撰写本文时,Docker v20.10.0 无法与 Docker Machine 一起使用。
这需要几分钟时间。一旦完成,在node-1
初始化群模式:
`$ docker-machine ssh node-1 -- docker swarm init --advertise-addr $(docker-machine ip node-1)`
从上一个命令的输出中获取 join 令牌,然后将剩余的节点作为 workers 添加到群中:
`$ for i in 2 3 4; do
docker-machine ssh node-$i \
-- docker swarm join --token YOUR_JOIN_TOKEN;
done`
将 Docker 守护进程指向node-1
并部署堆栈:
`$ eval $(docker-machine env node-1)
$ docker stack deploy --compose-file=docker-compose-swarm.yml flask`
列出堆栈中的服务:
`$ docker stack ps -f "desired-state=running" flask`
您应该会看到类似如下的内容:
`ID NAME IMAGE NODE DESIRED STATE CURRENT STATE
uz84le3651f8 flask_nginx.1 mjhea0/flask-docker-swarm_nginx:latest node-3 Running Running 23 seconds ago
nv365bhsoek1 flask_web.1 mjhea0/flask-docker-swarm_web:latest node-2 Running Running 32 seconds ago
uyl11jk2h71d flask_db.1 mjhea0/flask-docker-swarm_db:latest node-1 Running Running 38 seconds ago`
现在,为了根据web
服务中提供的模式更新数据库,我们首先需要将 Docker 守护进程指向运行flask_web
的节点:
`$ NODE=$(docker service ps -f "desired-state=running" --format "{{.Node}}" flask_web)
$ eval $(docker-machine env $NODE)`
将flask_web
的容器 ID 分配给一个变量:
`$ CONTAINER_ID=$(docker ps --filter name=flask_web --format "{{.ID}}")`
创建数据库表并应用种子:
`$ docker container exec -it $CONTAINER_ID python manage.py recreate_db
$ docker container exec -it $CONTAINER_ID python manage.py seed_db`
最后,将 Docker 守护进程指向node-1
,并检索与运行flask_nginx
的机器相关联的 IP:
`$ eval $(docker-machine env node-1)
$ docker-machine ip $(docker service ps -f "desired-state=running" --format "{{.Node}}" flask_nginx)`
测试端点:
让我们向集群添加另一个 web 应用程序:
`$ docker service scale flask_web=2
flask_web scaled to 2
overall progress: 2 out of 2 tasks
1/2: running [==================================================>]
2/2: running [==================================================>]
verify: Service converged`
确认服务确实可以扩展:
`$ docker stack ps -f "desired-state=running" flask
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE
uz84le3651f8 flask_nginx.1 mjhea0/flask-docker-swarm_nginx:latest node-3 Running Running 7 minutes ago
nv365bhsoek1 flask_web.1 mjhea0/flask-docker-swarm_web:latest node-2 Running Running 7 minutes ago
uyl11jk2h71d flask_db.1 mjhea0/flask-docker-swarm_db:latest node-1 Running Running 7 minutes ago
n8ld0xkm3pd0 flask_web.2 mjhea0/flask-docker-swarm_web:latest node-4 Running Running 7 seconds ago`
向服务提出一些请求:
`$ for ((i=1;i<=10;i++)); do curl http://YOUR_MACHINE_IP/ping; done`
您应该看到不同的container_id
被返回,表明请求通过两个副本之间的循环算法被适当地路由:
`{"container_id":"3e984eb707ea","message":"pong!","status":"success"}
{"container_id":"e47de2a13a2e","message":"pong!","status":"success"}
{"container_id":"3e984eb707ea","message":"pong!","status":"success"}
{"container_id":"e47de2a13a2e","message":"pong!","status":"success"}
{"container_id":"3e984eb707ea","message":"pong!","status":"success"}
{"container_id":"e47de2a13a2e","message":"pong!","status":"success"}
{"container_id":"3e984eb707ea","message":"pong!","status":"success"}
{"container_id":"e47de2a13a2e","message":"pong!","status":"success"}
{"container_id":"3e984eb707ea","message":"pong!","status":"success"}
{"container_id":"e47de2a13a2e","message":"pong!","status":"success"}`
如果我们在流量到达集群时进行扩展,会发生什么情况?
流量被适当地重新路由。再试一次,但这次要横向扩展。
Docker 群体可视化工具
Docker Swarm Visualizer 是一款开源工具,用于监控 Docker Swarm 集群。
将服务添加到 docker-compose-swarm.yml :
`visualizer: image: dockersamples/visualizer:latest ports: - 8080:8080 volumes: - "/var/run/docker.sock:/var/run/docker.sock" deploy: placement: constraints: [node.role == manager] networks: - app`
将 Docker 守护进程指向node-1
并更新堆栈:
`$ eval $(docker-machine env node-1)
$ docker stack deploy --compose-file=docker-compose-swarm.yml flask`
可视化工具可能需要一两分钟才能启动。导航至http://YOUR _ MACHINE _ IP:8080查看仪表板:
再添加两个flask_web
的副本:
`$ docker service scale flask_web=3`
码头工人的秘密
Docker Secrets 是一款专门为 Docker Swarm 设计的秘密管理工具。有了它,你可以轻松地分发敏感信息(如用户名和密码,SSH 密钥,SSL 证书,API 令牌等。)跨集群。
Docker 可以从自己的数据库(外部模式)或者从本地文件(文件模式)中读取秘密。我们将着眼于前者。
在services/web/project/API/main . py文件中,记下/secret
路径。如果请求有效载荷中的secret
与SECRET_CODE
变量相同,则响应有效载荷中的消息将等于yay!
。否则,它将等于nay!
。
`# yay
{
"container_id": "6f91a81a6357",
"message": "yay!",
"status": "success"
}
# nay
{
"container_id": "6f91a81a6357",
"message": "nay!",
"status": "success"
}`
测试终端中的/secret
端点:
`$ curl -X POST http://YOUR_MACHINE_IP/secret \
-d '{"secret": "myprecious"}' \
-H 'Content-Type: application/json'`
您应该看到:
`{
"container_id": "6f91a81a6357",
"message": "yay!",
"status": "success"
}`
让我们更新一下SECRET_CODE
,让它由 Docker Secret 而不是环境变量来设置。首先从 manager 节点创建一个新的密码:
`$ eval $(docker-machine env node-1)
$ echo "foobar" | docker secret create secret_code -`
确认它已创建:
您应该会看到类似这样的内容:
`ID NAME DRIVER CREATED UPDATED
za3pg2cbbf92gi9u1v0af16e3 secret_code 15 seconds ago 15 seconds ago`
接下来,删除SECRET_CODE
环境变量,并将secrets
配置添加到 docker-compose-swarm-yml 中的web
服务中:
`web: image: mjhea0/flask-docker-swarm_web:latest deploy: replicas: 1 restart_policy: condition: on-failure placement: constraints: [node.role == worker] expose: - 5000 environment: - FLASK_ENV=production - APP_SETTINGS=project.config.ProductionConfig - DB_USER=postgres - DB_PASSWORD=postgres secrets: - secret_code depends_on: - db networks: - app`
在文件的底部,将秘密的来源定义为external
,就在volumes
声明的下面:
`secrets: secret_code: external: true`
就是这样。我们可以在 Flask 应用程序中获得这个秘密。
查看 secrets 配置参考指南以及该堆栈溢出答案,了解有关外部和基于文件的机密的更多信息。
转回services/web/project/API/main . py。
改变:
`SECRET_CODE = os.environ.get("SECRET_CODE")`
收件人:
`SECRET_CODE = open("/run/secrets/secret_code", "r").read().strip()`
将 Docker 环境重置回本地主机:
`$ eval $(docker-machine env -u)`
重新构建映像并将新版本推送到 Docker Hub:
`$ docker build -t mjhea0/flask-docker-swarm_web:latest -f ./services/web/Dockerfile ./services/web
$ docker push mjhea0/flask-docker-swarm_web:latest`
将守护程序指向管理器,然后更新服务:
`$ eval $(docker-machine env node-1)
$ docker stack deploy --compose-file=docker-compose-swarm.yml flask`
有关在合成文件中定义机密的更多信息,请参考文档的在合成中使用机密部分。
再次测试:
`$ curl -X POST http://YOUR_MACHINE_IP/secret \
-d '{"secret": "foobar"}' \
-H 'Content-Type: application/json'
{
"container_id": "6f91a81a6357",
"message": "yay!",
"status": "success"
}`
想挑战吗?尝试使用 Docker 秘密来管理数据库凭证,而不是直接在合成文件中定义它们。
健康检查
在生产环境中,您应该在将流量路由到某个容器之前,使用健康检查来测试该容器是否按预期工作。在我们的例子中,我们可以使用健康检查来确保 Flask 应用程序(和 API)启动并运行;否则,我们可能会遇到这样的情况:一个新的容器启动并添加到集群中,该容器看起来是健康的,但实际上应用程序实际上是关闭的,无法处理流量。
您可以将健康检查添加到 docker 文件或撰写文件中。我们将着眼于后者。
想知道如何在 Dockerfile 文件中添加健康检查吗?查看官方文件中的健康检查说明。
值得注意的是,在撰写文件中定义的健康检查设置将覆盖 Dockerfile 文件中的设置。
像这样更新 docker-compose-swarm.yml 中的web
服务:
`web: image: mjhea0/flask-docker-swarm_web:latest deploy: replicas: 1 restart_policy: condition: on-failure placement: constraints: [node.role == worker] expose: - 5000 environment: - FLASK_ENV=production - APP_SETTINGS=project.config.ProductionConfig - DB_USER=postgres - DB_PASSWORD=postgres secrets: - secret_code depends_on: - db networks: - app healthcheck: test: curl --fail http://localhost:5000/ping || exit 1 interval: 10s timeout: 2s retries: 5`
选项:
test
是将运行来检查运行状况的实际命令。如果正常,它应该返回0
,如果不正常,应该返回1
。为此,curl 命令必须在容器中可用。- 容器启动后,
interval
控制第一次运行状况检查的时间和频率。 retries
设置在容器被视为不健康之前,健康检查将重试失败检查的次数。- 如果一次运行状况检查花费的时间超过了
timeout
中定义的时间,则该运行将被视为失败。
在测试健康检查之前,我们需要向容器添加 curl。记住:您用于运行状况检查的命令需要在容器内部可用。
像这样更新 Dockerfile :
`###########
# BUILDER #
###########
# Base Image
FROM python:3.9 as builder
# Lint
RUN pip install flake8 black
WORKDIR /home/app
COPY project ./project
COPY manage.py .
RUN flake8 --ignore=E501 .
RUN black --check .
# Install Requirements
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /home/app/wheels -r requirements.txt
#########
# FINAL #
#########
# Base Image
FROM python:3.9-slim
# ----- NEW ----
# Install curl
RUN apt-get update && apt-get install -y curl
# Create directory for the app user
RUN mkdir -p /home/app
# Create the app user
RUN groupadd app && useradd -g app app
# Create the home directory
ENV HOME=/home/app
ENV APP_HOME=/home/app/web
RUN mkdir $APP_HOME
WORKDIR $APP_HOME
# Install Requirements
COPY --from=builder /home/app/wheels /wheels
COPY --from=builder /home/app/requirements.txt .
RUN pip install --no-cache /wheels/*
# Copy in the Flask code
COPY . $APP_HOME
# Chown all the files to the app user
RUN chown -R app:app $APP_HOME
# Change to the app user
USER app
# run server
CMD gunicorn --log-level=debug -b 0.0.0.0:5000 manage:app`
再次,重置 Docker 环境:
`$ eval $(docker-machine env -u)`
建立并推广新形象:
`$ docker build -t mjhea0/flask-docker-swarm_web:latest -f ./services/web/Dockerfile ./services/web
$ docker push mjhea0/flask-docker-swarm_web:latest`
更新服务:
`$ eval $(docker-machine env node-1)
$ docker stack deploy --compose-file=docker-compose-swarm.yml flask`
然后,找到flask_web
服务所在的节点:
`$ docker service ps flask_web`
将守护程序指向该节点:
`$ eval $(docker-machine env <NODE>)`
确保用实际的节点替换
<NODE>
——例如node-2
、node-3
或node-4
。
获取容器 ID:
然后运行:
`$ docker inspect --format='{{json .State.Health}}' <CONTAINER_ID>`
您应该会看到类似这样的内容:
`{ "Status": "healthy", "FailingStreak": 0, "Log": [ { "Start": "2021-02-23T03:31:44.886509504Z", "End": "2021-02-23T03:31:45.104507568Z", "ExitCode": 0, "Output": " % Total % Received % Xferd Average Speed Time Time Time Current\n Dload Upload Total Spent Left Speed\n\r 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0\r100 69 100 69 0 0 11629 0 --:--:-- --:--:-- --:--:-- 13800\n{\"container_id\":\"a6127b1f469d\",\"message\":\"pong!\",\"status\":\"success\"}\n" } ] }`
想看看失败的健康检查吗?将 docker-compose-swarm.yml 中的test
命令更新为 ping 端口 5001 而不是 5000:
`healthcheck: test: curl --fail http://localhost:5001/ping || exit 1 interval: 10s timeout: 2s retries: 5`
就像之前一样,更新服务,然后找到flask_web
服务所在的节点和容器 id。然后,运行:
`$ docker inspect --format='{{json .State.Health}}' <CONTAINER_ID>`
您应该会看到类似这样的内容:
`{ "Status": "starting", "FailingStreak": 1, "Log": [ { "Start": "2021-02-23T03:34:39.644618421Z", "End": "2021-02-23T03:34:39.784855122Z", "ExitCode": 1, "Output": " % Total % Received % Xferd Average Speed Time Time Time Current\n Dload Upload Total Spent Left Speed\n\r 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0curl: (7) Failed to connect to localhost port 5001: Connection refused\n" } ] }`
Docker Swarm Visualizer 仪表盘中的服务也应该关闭。
更新运行状况检查和服务。在继续前进之前确保一切正常。
记录
在使用分布式系统时,设置适当的日志记录和监控非常重要,这样当出现问题时,您就可以深入了解发生了什么。我们已经设置了 Docker Swarm Visualizer 工具来帮助监控,但还有更多的事情可以做。
在日志记录方面,您可以运行以下命令(从节点管理器)来访问在多个节点上运行的服务的日志:
`$ docker service logs -f SERVICE_NAME`
查看文档,了解更多关于 logs 命令以及如何配置默认日志驱动程序的信息。
尝试一下:
`$ eval $(docker-machine env node-1)
$ docker service logs -f flask_web`
您可能希望汇总每个服务的日志事件,以帮助简化分析和可视化。一种流行的方法是在 Swarm 集群中建立一个 ELK (Elasticsearch、Logstash 和 Kibana)栈。这超出了这篇博文的范围,但是可以看看下面的资源来获得帮助:
最后, Prometheus (连同它事实上的 GUI Grafana )是一个强大的监控解决方案。查看Docker Swarm instrumentation with Prometheus了解更多信息。
都搞定了?
关闭堆栈并移除节点:
`$ docker stack rm flask
$ docker-machine rm node-1 node-2 node-3 node-4 -y`
自动化脚本
准备好把所有东西放在一起了吗?让我们编写一个脚本,它将:
- 用 Docker 机器提供液滴
- 配置 Docker 群组模式
- 向群集添加节点
- 创建新的 Docker 密码
- 部署 Flask 微服务
- 创建数据库表并应用种子
将名为 deploy.sh 的新文件添加到项目根目录:
`#!/bin/bash
echo "Spinning up four droplets..."
for i in 1 2 3 4; do
docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
node-$i;
done
echo "Initializing Swarm mode..."
docker-machine ssh node-1 -- docker swarm init --advertise-addr $(docker-machine ip node-1)
echo "Adding the nodes to the Swarm..."
TOKEN=`docker-machine ssh node-1 docker swarm join-token worker | grep token | awk '{ print $5 }'`
for i in 2 3 4; do
docker-machine ssh node-$i \
-- docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377;
done
echo "Creating secret..."
eval $(docker-machine env node-1)
echo "foobar" | docker secret create secret_code -
echo "Deploying the Flask microservice..."
docker stack deploy --compose-file=docker-compose-swarm.yml flask
echo "Create the DB table and apply the seed..."
sleep 15
NODE=$(docker service ps -f "desired-state=running" --format "{{.Node}}" flask_web)
eval $(docker-machine env $NODE)
CONTAINER_ID=$(docker ps --filter name=flask_web --format "{{.ID}}")
docker container exec -it $CONTAINER_ID python manage.py recreate_db
docker container exec -it $CONTAINER_ID python manage.py seed_db
echo "Get the IP address..."
eval $(docker-machine env node-1)
docker-machine ip $(docker service ps -f "desired-state=running" --format "{{.Node}}" flask_nginx)`
试试吧!
完成后将水滴带下来:
`$ docker-machine rm node-1 node-2 node-3 node-4 -y`
结论
在这篇文章中,我们看了如何通过 Docker Swarm 在 DigitalOcean 上运行 Flask 应用程序。
此时,您应该了解 Docker Swarm 的工作原理,并能够部署一个运行应用程序的集群。在生产中使用 Docker Swarm 之前,请确保深入了解一些更高级的主题,如日志记录、监控和使用滚动更新来实现零停机部署。
你可以在 GitHub 上的 flask-docker-swarm repo 中找到代码。
在 Kubernetes 上运行烧瓶
在本教程中,我们将首先从总体上了解 Kubernetes 和容器编排,然后我们将逐步详细介绍如何将基于 Flask 的微服务(以及 Postgres 和 Vue.js)部署到 Kubernetes 集群。
这是一个中级教程。它假定你有烧瓶和 Docker 的基本工作知识。查看使用 Python、Flask 和 Docker 进行测试驱动开发课程,了解关于这些工具的更多信息。
依赖关系:
- kubernetes 1 . 21 . 0 版
- minikube 1 . 19 . 0 版
- 文档 v20.10.5
- 坞站-复合 v1.28.5
目标
本教程结束时,您将能够:
- 解释什么是容器编排,以及为什么需要使用编排工具
- 讨论使用 Kubernetes 相对于其他编排工具(如 Docker Swarm 和弹性容器服务(ECS ))的利弊
- 解释以下 Kubernetes 原语:节点、Pod、服务、标签、部署、入口和卷
- 使用 Docker Compose 在本地构建基于 Python 的微服务
- 将 Kubernetes 集群配置为使用 Minikube 在本地运行
- 设置一个卷来保存 Kubernetes 集群中的 Postgres 数据
- 使用 Kubernetes 的秘密来管理敏感信息
- 在 Kubernetes 上运行 Flask,Gunicorn,Postgres 和 Vue
- 通过入口将烧瓶和 Vue 暴露给外部用户
什么是容器编排?
当您从在单台机器上部署容器转移到在多台机器上部署容器时,您将需要一个编排工具来管理(并自动化)容器在整个系统中的安排、协调和可用性。
编排工具有助于:
- 跨服务器容器通信
- 水平缩放
- 服务发现
- 负载平衡
- 安全性/TLS
- 零停机部署
- 卷回
- 记录
- 监视
这就是 Kubernetes 与其他一些编排工具的契合之处,比如 T2、Docker Swarm、T4、ECS、Mesos 和 Nomad。
你应该用哪一个?
- 如果您需要管理大型、复杂的集群,请使用 Kubernetes
- 如果您刚刚起步和/或需要管理中小型集群,请使用 Docker Swarm
- 如果您已经在使用一些 AWS 服务,请使用 ECS
工具 | 赞成的意见 | 骗局 |
---|---|---|
库伯内特斯 | 大型社区,灵活,大多数功能,时尚 | 复杂的设置、高学习曲线、hip |
码头工人群 | 易于设置,非常适合小型集群 | 受 Docker API 的限制 |
精英公司 | 全面管理的服务,与 AWS 集成 | 供应商锁定 |
市场上还有许多由 Kubernetes 管理的服务:
- 谷歌 Kubernetes 引擎 (GKE)
- 弹性 Kubernetes 服务 (EKS)
- Azure Kubernetes 服务公司
- 数字海洋 Kubernetes
更多信息,请查看选择正确的容器化和集群管理工具博文。
不可思议的概念
在开始之前,让我们先来看看一些您必须使用的来自 Kubernetes API 的基本构件:
- 一个 节点 是一个用于运行 Kubernetes 的工作机。每个节点都由 Kubernetes 主节点管理。
- 一个 Pod 是在一个节点上运行的一组逻辑紧密耦合的应用程序容器。Pod 中的容器部署在一起并共享资源(如数据量和网络地址)。多个单元可以在一个节点上运行。
- 一个 服务 是执行类似功能的一组逻辑单元。它支持负载平衡和服务发现。它是豆荚上的一个抽象层;豆荚是短暂的,而服务是持久的。
- 部署 用于描述 Kubernetes 的期望状态。它们规定了如何创建、部署和复制 pod。
- 标签 是附属于资源(如 pod)的键/值对,用于组织相关资源。你可以把它们想象成 CSS 选择器。例如:
- 环境 -
dev
,test
,prod
- App 版本 -
beta
,1.2.1
- 类型 -
client
,server
,db
- 环境 -
- Ingress 是一组路由规则,用于根据请求主机或路径控制外部对服务的访问。
- 卷 用于保存容器寿命之外的数据。它们对于像 Redis 和 Postgres 这样的有状态应用程序尤其重要。
更多信息,请查看学习 Kubernetes 基础知识教程以及来自Kubernetes演讲的 Kubernetes 概念幻灯片。
项目设置
克隆出flask-vue-kubernetesrepo,然后构建映像并旋转容器:
`$ git clone https://github.com/testdrivenio/flask-vue-kubernetes
$ cd flask-vue-kubernetes
$ docker-compose up -d --build`
创建并植入数据库books
表:
`$ docker-compose exec server python manage.py recreate_db
$ docker-compose exec server python manage.py seed_db`
在您选择的浏览器中测试以下服务器端端点。
http://localhost:5001/books/ping
`{ "container_id": "dee114fa81ea", "message": "pong!", "status": "success" }`
container_id
是应用程序运行所在的 Docker 容器的 id。
`$ docker ps --filter name=flask-vue-kubernetes_server --format "{{.ID}}"
dee114fa81ea`
`{ "books": [{ "author": "J. K. Rowling", "id": 2, "read": false, "title": "Harry Potter and the Philosopher's Stone" }, { "author": "Dr. Seuss", "id": 3, "read": true, "title": "Green Eggs and Ham" }, { "author": "Jack Kerouac", "id": 1, "read": false, "title": "On the Road" }], "container_id": "dee114fa81ea", "status": "success" }`
导航到 http://localhost:8080 。确保基本 CRUD 功能按预期工作:
在继续之前,快速浏览一下代码:
`├── .gitignore
├── README.md
├── deploy.sh
├── docker-compose.yml
├── kubernetes
│ ├── flask-deployment.yml
│ ├── flask-service.yml
│ ├── minikube-ingress.yml
│ ├── persistent-volume-claim.yml
│ ├── persistent-volume.yml
│ ├── postgres-deployment.yml
│ ├── postgres-service.yml
│ ├── secret.yml
│ ├── vue-deployment.yml
│ └── vue-service.yml
└── services
├── client
│ ├── .babelrc
│ ├── .editorconfig
│ ├── .eslintignore
│ ├── .eslintrc.js
│ ├── .postcssrc.js
│ ├── Dockerfile
│ ├── Dockerfile-minikube
│ ├── README.md
│ ├── build
│ ├── config
│ │ ├── dev.env.js
│ │ ├── index.js
│ │ └── prod.env.js
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ │ ├── App.vue
│ │ ├── assets
│ │ │ └── logo.png
│ │ ├── components
│ │ │ ├── Alert.vue
│ │ │ ├── Books.vue
│ │ │ ├── HelloWorld.vue
│ │ │ └── Ping.vue
│ │ ├── main.js
│ │ └── router
│ │ └── index.js
│ └── static
│ └── .gitkeep
├── db
│ ├── create.sql
│ └── Dockerfile
└── server
├── .dockerignore
├── Dockerfile
├── entrypoint.sh
├── manage.py
├── project
│ ├── __init__.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── books.py
│ │ └── models.py
│ └── config.py
└── requirements.txt`
想学习如何构建这个项目吗?查看用 Flask 和 Vue.js 开发单页应用的博文。
迷你库贝
Minikube 是一个允许开发者在本地使用和运行 Kubernetes 集群的工具。这是快速启动和运行集群的好方法,这样您就可以开始与 Kubernetes API 进行交互。
遵循官方的入门指南,安装 Minikube 以及:
- 一个虚拟机管理程序(像 VirtualBox 或 HyperKit )来管理虚拟机
- 在 Kubernetes 上部署和管理应用程序
如果你用的是 Mac,我们建议用 Homebrew 安装 Kubectl 和 Minikube:
`$ brew update
$ brew install kubectl
$ brew install minikube`
然后,启动组合仪表并拉起 Minikube 仪表盘:
`$ minikube config set vm-driver hyperkit
$ minikube start
$ minikube dashboard`
值得注意的是,配置文件将位于 ~/中。kube 目录,而所有的虚拟机位将在 ~/中。minikube 目录。
现在我们可以开始通过 Kubernetes API 创建对象了。
如果你遇到了 Minikube 的问题,通常最好是完全删除它并重新开始。
例如:
`$ minikube stop; minikube delete
$ rm /usr/local/bin/minikube
$ rm -rf ~/.minikube
# re-download minikube
$ minikube start`
创建对象
要在 Kubernetes 中创建新的对象,您必须提供一个描述其期望状态的“规范”。
示例:
`apiVersion: apps/v1 kind: Deployment metadata: name: flask spec: replicas: 1 template: metadata: labels: app: flask spec: containers: - name: flask image: mjhea0/flask-kubernetes:latest ports: - containerPort: 5000`
必填字段:
apiVersion
- 【立方 API】版本kind
-您想要创建的对象的类型metadata
-关于物体的信息,以便可以唯一识别spec
-目标的期望状态
在上面的例子中,这个规范将为 Flask 应用程序创建一个新的部署,带有一个副本(Pod)。注意containers
部分。这里,我们指定了 Docker 映像以及应用程序将运行的容器端口。
为了运行我们的应用程序,我们需要设置以下对象:
卷
同样,由于容器是短暂的,我们需要配置一个卷,通过一个 PersistentVolume 和一个 PersistentVolumeClaim 来存储 Pod 外部的 Postgres 数据。
注意kubernetes/persistent-volume . yml中的 YAML 文件:
`apiVersion: v1 kind: PersistentVolume metadata: name: postgres-pv labels: type: local spec: capacity: storage: 2Gi storageClassName: standard accessModes: - ReadWriteOnce hostPath: path: "/data/postgres-pv"`
此配置将在节点内的“/data/postgres-pv”处创建一个主机路径持久卷。卷的大小为 2gb,访问模式为 ReadWriteOnce ,这意味着卷可以通过单个节点以读写方式装载。
值得注意的是,Kubernetes 只支持在单节点集群上使用 hostPath。
创建卷:
`$ kubectl apply -f ./kubernetes/persistent-volume.yml`
查看详细信息:
您应该看到:
`NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
postgres-pv 2Gi RWO Retain Available standard 14s`
您还应该在仪表板中看到该对象:
立方/持久-体积索赔. yml :
`apiVersion: v1 kind: PersistentVolumeClaim metadata: name: postgres-pvc labels: type: local spec: accessModes: - ReadWriteOnce resources: requests: storage: 2Gi volumeName: postgres-pv storageClassName: standard`
创建体积索赔:
`$ kubectl apply -f ./kubernetes/persistent-volume-claim.yml`
查看详细信息:
`$ kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
postgres-pvc Bound postgres-pv 2Gi RWO standard 15s`
秘密
秘密用于处理敏感信息,如密码、API 令牌和 SSH 密钥。我们将设置一个秘密来存储我们的 Postgres 数据库凭证。
立方/秘密。yml :
`apiVersion: v1 kind: Secret metadata: name: postgres-credentials type: Opaque data: user: c2FtcGxl password: cGxlYXNlY2hhbmdlbWU=`
user
和password
字段是 base64 编码的字符串(安全性通过模糊性):
`$ echo -n "pleasechangeme" | base64
cGxlYXNlY2hhbmdlbWU=
$ echo -n "sample" | base64
c2FtcGxl`
请记住,任何有权访问群集的用户都能够以纯文本形式读取这些值。如果你想加密传输中的和静止的秘密,看看保险库。
添加机密对象:
`$ kubectl apply -f ./kubernetes/secret.yml`
Postgres
在集群中设置了卷和数据库凭证后,我们现在可以配置 Postgres 数据库本身。
kubrintes/posters 部署. yml :
`apiVersion: apps/v1 kind: Deployment metadata: name: postgres labels: name: database spec: replicas: 1 selector: matchLabels: service: postgres template: metadata: labels: service: postgres spec: containers: - name: postgres image: postgres:13-alpine env: - name: POSTGRES_USER valueFrom: secretKeyRef: name: postgres-credentials key: user - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-credentials key: password volumeMounts: - name: postgres-volume-mount mountPath: /var/lib/postgresql/data volumes: - name: postgres-volume-mount persistentVolumeClaim: claimName: postgres-pvc restartPolicy: Always`
这里发生了什么事?
metadata
name
字段定义了部署名称-postgres
labels
为部署定义标签-name: database
spec
replicas
定义要运行的 pod 数量-1
selector
定义部署如何找到要管理的单元template
metadata
labels
指出哪些标签应该分配给 Pod -service: postgres
spec
containers
定义与每个 Pod 相关的容器volumes
定义体积索赔-postgres-volume-mount
restartPolicy
定义了重启策略 -Always
进一步,Pod 名称为postgres
,图像为postgres:13-alpine
,将从 Docker Hub 中拉取。来自秘密对象的数据库凭证也被传入。
最后,在应用时,卷声明将被安装到 Pod 中。声明被装载到“/var/lib/PostgreSQL/data”——默认位置——而数据将存储在持久卷“/data/postgres-pv”中。
创建部署:
`$ kubectl create -f ./kubernetes/postgres-deployment.yml`
状态:
`$ kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
postgres 1/1 1 1 12s`
立方/研究生服务. yml :
`apiVersion: v1 kind: Service metadata: name: postgres labels: service: postgres spec: selector: service: postgres type: ClusterIP ports: - port: 5432`
这里发生了什么事?
metadata
name
字段定义了服务名-postgres
labels
定义服务的标签-name: database
spec
selector
定义服务适用的 Pod 标签和值-service: postgres
type
定义了ClusterIP
服务的类型ports
port
定义暴露给集群的端口
花点时间回到部署规范。服务中的
selector
如何与部署相关联?
由于服务类型是ClusterIP
,它没有对外公开,所以它是唯一的,可以被其他对象从集群内部访问。
创建服务:
`$ kubectl create -f ./kubernetes/postgres-service.yml`
使用 Pod 名称创建books
数据库:
`$ kubectl get pods
NAME READY STATUS RESTARTS AGE
postgres-95566f9-xs2cf 1/1 Running 0 93s
$ kubectl exec postgres-95566f9-xs2cf --stdin --tty -- createdb -U sample books`
验证创建:
`$ kubectl exec postgres-95566f9-xs2cf --stdin --tty -- psql -U sample
psql (13.2)
Type "help" for help.
sample=# \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+----------+----------+------------+------------+-----------------------
books | sample | UTF8 | en_US.utf8 | en_US.utf8 |
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
sample | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
(5 rows)
sample=#`
您也可以通过以下方式获取 Pod 名称:
`$ kubectl get pod -l service=postgres -o jsonpath="{.items[0].metadata.name}"`
将值赋给变量,然后创建数据库:
`$ POD_NAME=$(kubectl get pod -l service=postgres -o jsonpath="{.items[0].metadata.name}")
$ kubectl exec $POD_NAME --stdin --tty -- createdb -U sample books`
瓶
花点时间回顾一下 Flask 项目结构以及docker 文件和 entrypoint.sh 文件:
- "服务/服务器"
- 服务/服务器/Dockerfile
- 服务/服务器/入口点. sh
立方/flask 部署. yml :
`apiVersion: apps/v1 kind: Deployment metadata: name: flask labels: name: flask spec: replicas: 1 selector: matchLabels: app: flask template: metadata: labels: app: flask spec: containers: - name: flask image: mjhea0/flask-kubernetes:latest env: - name: FLASK_ENV value: "development" - name: APP_SETTINGS value: "project.config.DevelopmentConfig" - name: POSTGRES_USER valueFrom: secretKeyRef: name: postgres-credentials key: user - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-credentials key: password restartPolicy: Always`
这应该类似于 Postgres 部署规范。最大的不同是,你可以在 Docker Hub 、mjhea0/flask-kubernetes
上使用我预先构建和推送的图像,或者构建和推送你自己的图像。
例如:
`$ docker build -t <YOUR_DOCKER_HUB_NAME>/flask-kubernetes ./services/server
$ docker push <YOUR_DOCKER_HUB_NAME>/flask-kubernetes`
如果您使用自己的名称,请确保在kubernetes/flashboard-deploy . yml中将mjhea0
替换为您的 Docker Hub 名称。
或者,如果不想将映像推送到 Docker 注册表,在本地构建映像后,可以将
image-pull-policy
标志设置为Never
以始终使用本地映像。
创建部署:
`$ kubectl create -f ./kubernetes/flask-deployment.yml`
这将立即旋转一个新的 Pod:
久效磷/flask-service.yml :
`apiVersion: v1 kind: Service metadata: name: flask labels: service: flask spec: selector: app: flask ports: - port: 5000 targetPort: 5000`
好奇
targetPort
和port
有什么关系?查看官方服务指南。
创建服务:
`$ kubectl create -f ./kubernetes/flask-service.yml`
确保 Pod 与服务相关联:
应用迁移并为数据库设定种子:
`$ kubectl get pods
NAME READY STATUS RESTARTS AGE
flask-66988cb97d-n88b4 1/1 Running 0 21m
postgres-95566f9-xs2cf 1/1 Running 0 36m`
`$ kubectl exec flask-66988cb97d-n88b4 --stdin --tty -- python manage.py recreate_db
$ kubectl exec flask-66988cb97d-n88b4 --stdin --tty -- python manage.py seed_db`
验证:
`$ kubectl exec postgres-95566f9-xs2cf --stdin --tty -- psql -U sample
psql (13.2)
Type "help" for help.
sample=# \c books
You are now connected to database "books" as user "sample".
books=# select * from books;
id | title | author | read
----+------------------------------------------+---------------+------
1 | On the Road | Jack Kerouac | t
2 | Harry Potter and the Philosopher's Stone | J. K. Rowling | f
3 | Green Eggs and Ham | Dr. Seuss | t
(3 rows)`
进入
要使流量能够访问集群内部的 Flask API,您可以使用节点端口、负载平衡器或入口:
要了解更多信息,请查看官方的出版服务指南。
kubernetes/minikube-ingress . yml:
`apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: minikube-ingress annotations: spec: rules: - host: hello.world http: paths: - path: / pathType: Prefix backend: service: name: vue port: number: 8080 - path: /books pathType: Prefix backend: service: name: flask port: number: 5000`
这里,我们定义了以下 HTTP 规则:
/
-将请求路由到 Vue 服务(我们仍然需要设置它)/books
-将请求路由到 Flask 服务
启用入口插件:
`$ minikube addons enable ingress`
创建入口对象:
`$ kubectl apply -f ./kubernetes/minikube-ingress.yml`
如果您看到一个
Internal error occurred: failed calling webhook "validate.nginx.ingress.kubernetes.io"
错误,尝试移除ValidatingWebhookConfiguration
:$ kubectl delete -A ValidatingWebhookConfiguration ingress-nginx-admission
有关更多信息,请查看此堆栈溢出线程。
接下来,您需要更新您的 /etc/hosts 文件,以便将请求从我们定义的主机hello.world
路由到 Minikube 实例。
向 /etc/hosts 添加一个条目:
`$ echo "$(minikube ip) hello.world" | sudo tee -a /etc/hosts`
尝试一下:
http://hello.world/books/ping:
`{ "container_id": "flask-66988cb97d-n88b4", "message":"pong!", "status": "success" }`
`{ "books": [{ "author": "Jack Kerouac", "id": 1, "read": true, "title": "On the Road" }, { "author": "J. K. Rowling", "id": 2, "read": false, "title": "Harry Potter and the Philosopher's Stone" }, { "author": "Dr. Seuss", "id": 3, "read": true, "title": "Green Eggs and Ham" }], "container_id": "flask-66988cb97d-n88b4", "status": "success" }`
某视频剪辑软件
继续,查看 Vue 项目以及相关的 docker 文件:
- "服务/客户"
- /服务/客户端/Dockerfile
- /服务/客户端/Dockerfile-minikube
立方/视图部署. yml :
`apiVersion: apps/v1 kind: Deployment metadata: name: vue labels: name: vue spec: replicas: 1 selector: matchLabels: app: vue template: metadata: labels: app: vue spec: containers: - name: vue image: mjhea0/vue-kubernetes:latest restartPolicy: Always`
同样,要么使用我的映像,要么构建您自己的映像并推送到 Docker Hub:
`$ docker build -t <YOUR_DOCKERHUB_NAME>/vue-kubernetes ./services/client \
-f ./services/client/Dockerfile-minikube
$ docker push <YOUR_DOCKERHUB_NAME>/vue-kubernetes`
创建部署:
`$ kubectl create -f ./kubernetes/vue-deployment.yml`
验证 Pod 是否已随部署一起创建:
`$ kubectl get deployments vue
NAME READY UP-TO-DATE AVAILABLE AGE
vue 1/1 1 1 40s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
flask-66988cb97d-n88b4 1/1 Running 0 37m
postgres-95566f9-xs2cf 1/1 Running 0 71m
vue-cd9d7d445-xl7wd 1/1 Running 0 2m32s`
如何在仪表板中验证 Pod 和部署是否已成功创建?
立方/视图服务. yml :
`apiVersion: v1 kind: Service metadata: name: vue labels: service: vue name: vue spec: selector: app: vue ports: - port: 8080 targetPort: 8080`
创建服务:
`$ kubectl create -f ./kubernetes/vue-service.yml`
确保http://hello.world/按预期工作。
缩放比例
Kubernetes 使其易于扩展,当流量负载变得超过单个单元的处理能力时,可以根据需要添加额外的单元。
例如,让我们向集群添加另一个 Flask Pod:
`$ kubectl scale deployment flask --replicas=2`
确认:
`$ kubectl get deployments flask
NAME READY UP-TO-DATE AVAILABLE AGE
flask 2/2 2 2 11m`
`$ kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
flask-66988cb97d-hqpbh 1/1 Running 0 27s 172.17.0.10 minikube <none> <none>
flask-66988cb97d-n88b4 1/1 Running 0 39m 172.17.0.7 minikube <none> <none>
postgres-95566f9-xs2cf 1/1 Running 0 74m 172.17.0.6 minikube <none> <none>
vue-cd9d7d445-xl7wd 1/1 Running 0 5m18s 172.17.0.9 minikube <none> <none>`
向服务提出一些请求:
`$ for ((i=1;i<=10;i++)); do curl http://hello.world/books/ping; done`
您应该看到不同的container_id
被返回,表明请求通过两个副本之间的循环算法被适当地路由:
`{"container_id":"flask-66988cb97d-n88b4","message":"pong!","status":"success"}
{"container_id":"flask-66988cb97d-hqpbh","message":"pong!","status":"success"}
{"container_id":"flask-66988cb97d-hqpbh","message":"pong!","status":"success"}
{"container_id":"flask-66988cb97d-n88b4","message":"pong!","status":"success"}
{"container_id":"flask-66988cb97d-n88b4","message":"pong!","status":"success"}
{"container_id":"flask-66988cb97d-hqpbh","message":"pong!","status":"success"}
{"container_id":"flask-66988cb97d-n88b4","message":"pong!","status":"success"}
{"container_id":"flask-66988cb97d-hqpbh","message":"pong!","status":"success"}
{"container_id":"flask-66988cb97d-n88b4","message":"pong!","status":"success"}
{"container_id":"flask-66988cb97d-hqpbh","message":"pong!","status":"success"}`
如果在流量冲击集群时缩减规模,会发生什么情况?打开两个终端窗口,并在上进行测试。您应该会看到流量被适当地重新路由。再试一次,但这次要放大。
有用的命令
命令 | 说明 |
---|---|
minikube start |
启动本地 Kubernetes 集群 |
minikube ip |
显示集群的 IP 地址 |
minikube dashboard |
在浏览器中打开 Kubernetes 仪表板 |
kubectl version |
显示 Kubectl 版本 |
kubectl cluster-info |
显示集群信息 |
kubectl get nodes |
列出节点 |
kubectl get pods |
列出了窗格 |
kubectl get deployments |
列出部署 |
kubectl get services |
列出服务 |
minikube stop |
停止本地 Kubernetes 集群 |
minikube delete |
删除本地 Kubernetes 集群 |
查看 Kubernetes 备忘单了解更多命令。
自动化脚本
准备好把所有东西放在一起了吗?
看看项目根目录中的 deploy.sh 脚本。这个脚本:
- 创建一个持久卷和一个持久卷声明
- 通过 Kubernetes Secrets 添加数据库凭证
- 创建 Postgres 部署和服务
- 创建 Flask 部署和服务
- 启用入口
- 应用了入口规则
- 创建 Vue 部署和服务
`#!/bin/bash
echo "Creating the volume..."
kubectl apply -f ./kubernetes/persistent-volume.yml
kubectl apply -f ./kubernetes/persistent-volume-claim.yml
echo "Creating the database credentials..."
kubectl apply -f ./kubernetes/secret.yml
echo "Creating the postgres deployment and service..."
kubectl create -f ./kubernetes/postgres-deployment.yml
kubectl create -f ./kubernetes/postgres-service.yml
POD_NAME=$(kubectl get pod -l service=postgres -o jsonpath="{.items[0].metadata.name}")
kubectl exec $POD_NAME --stdin --tty -- createdb -U sample books
echo "Creating the flask deployment and service..."
kubectl create -f ./kubernetes/flask-deployment.yml
kubectl create -f ./kubernetes/flask-service.yml
FLASK_POD_NAME=$(kubectl get pod -l app=flask -o jsonpath="{.items[0].metadata.name}")
kubectl exec $FLASK_POD_NAME --stdin --tty -- python manage.py recreate_db
kubectl exec $FLASK_POD_NAME --stdin --tty -- python manage.py seed_db
echo "Adding the ingress..."
minikube addons enable ingress
kubectl delete -A ValidatingWebhookConfiguration ingress-nginx-admission
kubectl apply -f ./kubernetes/minikube-ingress.yml
echo "Creating the vue deployment and service..."
kubectl create -f ./kubernetes/vue-deployment.yml
kubectl create -f ./kubernetes/vue-service.yml`
试试吧!
完成后,创建books
数据库,应用迁移,并播种数据库:
`$ POD_NAME=$(kubectl get pod -l service=postgres -o jsonpath="{.items[0].metadata.name}")
$ kubectl exec $POD_NAME --stdin --tty -- createdb -U sample books
$ FLASK_POD_NAME=$(kubectl get pod -l app=flask -o jsonpath="{.items[0].metadata.name}")
$ kubectl exec $FLASK_POD_NAME --stdin --tty -- python manage.py recreate_db
$ kubectl exec $FLASK_POD_NAME --stdin --tty -- python manage.py seed_db`
更新 /etc/hosts ,然后在浏览器中测试出来。
结论
在本教程中,我们了解了如何在 Kubernetes 上运行基于 Flask 的微服务。
至此,您应该对 Kubernetes 的工作原理有了基本的了解,并且能够部署一个运行应用程序的集群。
其他资源:
- 学习立方基础
- 配置最佳实践
- 用 Kubernetes 刻度烧瓶
- 在 Docker Swarm 上运行烧瓶(比较和对比 Docker Swarm 与 Kubernetes 上的运行烧瓶)
- 使用 Kubernetes 将 Node 应用部署到 Google Cloud】
您可以在 GitHub 上的flask-vue-kubernetesrepo 中找到代码。
与 Docker Swarm 一起在数字海洋上运行火花
原文:https://testdriven.io/blog/running-spark-with-docker-swarm-on-digitalocean/
让我们看看如何将用于大规模数据处理的开源集群计算框架 Apache Spark 部署到 T2 数字海洋(DigitalOcean)上的 Docker Swarm 集群。我们还将了解如何根据需要自动配置(和取消配置)机器,以降低成本。
项目设置
克隆项目回购:
`$ git clone https://github.com/testdrivenio/spark-docker-swarm
$ cd spark-docker-swarm`
然后,从 Docker Hub 中拉出预建的spark
图像:
`$ docker pull mjhea0/spark:3.0.2`
Spark 版本 2.0.1,2.3.3,2.4.1 也有可用。
该图像大小约为 800MB,因此下载可能需要几分钟时间,这取决于您的连接速度。在等待它完成时,请随意查看用于构建该图像的 Dockerfile 以及 count.py ,我们将通过 Spark 运行它。
一旦提取,将SPARK_PUBLIC_DNS
环境变量设置为localhost
或 Docker 机器的 IP 地址:
`$ export EXTERNAL_IP=localhost`
SPARK_PUBLIC_DNS
设置 Spark 主机和工作机的公共 DNS 名称。
点燃容器:
`$ docker-compose up -d --build`
这将旋转火花主人和一个工人。在浏览器中导航到 Spark master 的 web UI,网址为 http://localhost:8080 :
要启动 Spark 工作,我们需要:
- 获取
master
服务的容器 ID,并将其分配给一个名为CONTAINER_ID
的环境变量 - 将 count.py 文件复制到
master
容器中的“/tmp”目录 - 运行作业!
尝试一下:
`# get container id, assign to env variable
$ export CONTAINER_ID=$(docker ps --filter name=master --format "{{.ID}}")
# copy count.py
$ docker cp count.py $CONTAINER_ID:/tmp
# run spark
$ docker exec $CONTAINER_ID \
bin/spark-submit \
--master spark://master:7077 \
--class endpoint \
/tmp/count.py`
跳回 Spark master 的 web UI。您应该会看到一个正在运行的作业:
在终端中,您应该会看到输出的火花日志。如果一切顺利,来自 counts.py 的get_counts()
函数的输出应该是:
有了这个,让我们来旋转一个蜂群吧!
码头工人群
首先,你需要注册一个数字海洋账户(如果你还没有的话),然后生成一个访问令牌,这样你就可以访问数字海洋 API 。
将令牌添加到您的环境中:
`$ export DIGITAL_OCEAN_ACCESS_TOKEN=[your_digital_ocean_token]`
旋转三个数字海洋液滴:
`$ for i in 1 2 3; do
docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
node-$i;
done`
在node-1
初始化群模式:
`$ docker-machine ssh node-1 \
-- docker swarm init \
--advertise-addr $(docker-machine ip node-1)`
从上一个命令的输出中获取 join 令牌,然后将剩余的节点作为 workers 添加到群中:
`$ for i in 2 3; do
docker-machine ssh node-$i \
-- docker swarm join --token YOUR_JOIN_TOKEN;
done`
耗尽蜂群管理器:
`$ docker-machine ssh node-1 -- docker node update --availability drain node-1`
清空群管理器使其不能运行任何容器是一个好的做法。
将 Docker 守护进程指向node-1
,更新EXTERNAL_IP
环境变量,并部署堆栈:
`$ eval $(docker-machine env node-1)
$ export EXTERNAL_IP=$(docker-machine ip node-2)
$ docker stack deploy --compose-file=docker-compose.yml spark`
添加另一个工作节点:
`$ docker service scale spark_worker=2`
查看堆栈:
您应该会看到类似如下的内容:
`ID NAME IMAGE NODE DESIRED STATE CURRENT STATE
uoz26a2zhpoh spark_master.1 mjhea0/spark:3.0.2 node-3 Running Running 23 seconds ago
ek7j1imsgvjy spark_worker.1 mjhea0/spark:3.0.2 node-2 Running Running 21 seconds ago
l7jz5s29rqrc spark_worker.2 mjhea0/spark:3.0.2 node-3 Running Running 24 seconds ago`
将 Docker 守护进程指向 Spark master 所在的节点:
`$ NODE=$(docker service ps --format "{{.Node}}" spark_master)
$ eval $(docker-machine env $NODE)`
获取 IP:
`$ docker-machine ip $NODE`
确保 Spark master 的 web UI 在http://YOUR _ MACHINE _ IP:8080打开。您还应该看到两个工人:
获取 Spark master 的容器 ID,并将其设置为环境变量:
`$ export CONTAINER_ID=$(docker ps --filter name=master --format "{{.ID}}")`
复制文件:
`$ docker cp count.py $CONTAINER_ID:/tmp`
测试:
`$ docker exec $CONTAINER_ID \
bin/spark-submit \
--master spark://master:7077 \
--class endpoint \
/tmp/count.py`
同样,您应该在 Spark master 的 web UI 中看到作业运行,同时在终端中看到输出的 Spark 日志。
作业完成后关闭节点:
`$ docker-machine rm node-1 node-2 node-3 -y`
自动化脚本
为了降低成本,您可以按需增加和配置资源——因此您只需为您使用的资源付费。
让我们编写几个脚本,它们将:
- 用 Docker 机器提供液滴
- 配置 Docker 群组模式
- 向群集添加节点
- 展开火花
- 进行火花作业
- 完成后,旋转水滴
create.sh :
`#!/bin/bash
echo "Spinning up three droplets..."
for i in 1 2 3; do
docker-machine create \
--driver digitalocean \
--digitalocean-access-token $DIGITAL_OCEAN_ACCESS_TOKEN \
--engine-install-url "https://releases.rancher.com/install-docker/19.03.9.sh" \
node-$i;
done
echo "Initializing Swarm mode..."
docker-machine ssh node-1 -- docker swarm init --advertise-addr $(docker-machine ip node-1)
docker-machine ssh node-1 -- docker node update --availability drain node-1
echo "Adding the nodes to the Swarm..."
TOKEN=`docker-machine ssh node-1 docker swarm join-token worker | grep token | awk '{ print $5 }'`
docker-machine ssh node-2 "docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377"
docker-machine ssh node-3 "docker swarm join --token ${TOKEN} $(docker-machine ip node-1):2377"
echo "Deploying Spark..."
eval $(docker-machine env node-1)
export EXTERNAL_IP=$(docker-machine ip node-2)
docker stack deploy --compose-file=docker-compose.yml spark
docker service scale spark_worker=2
echo "Get address..."
NODE=$(docker service ps --format "{{.Node}}" spark_master)
docker-machine ip $NODE`
run.sh :
`#!/bin/sh
echo "Getting container ID of the Spark master..."
eval $(docker-machine env node-1)
NODE=$(docker service ps --format "{{.Node}}" spark_master)
eval $(docker-machine env $NODE)
CONTAINER_ID=$(docker ps --filter name=master --format "{{.ID}}")
echo "Copying count.py script to the Spark master..."
docker cp count.py $CONTAINER_ID:/tmp
echo "Running Spark job..."
docker exec $CONTAINER_ID \
bin/spark-submit \
--master spark://master:7077 \
--class endpoint \
/tmp/count.py`
destroy.sh :
`#!/bin/bash
docker-machine rm node-1 node-2 node-3 -y`
测试一下!
代码可以在 spark-docker-swarm repo 中找到。干杯!
在库伯内特经营金库和领事
原文:https://testdriven.io/blog/running-vault-and-consul-on-kubernetes/
在下面的教程中,我们将带您在 Kubernetes 上使用 TLS 配置一个高度可用的 Hashicorp Vault 和 Consul 集群。
主要依赖:
- 保险库版本 1.7.1
- 领事 v1.9.5
- kubernetes 1 . 21 . 0 版
这是一个中级教程。它假设你有金库,领事,码头工人和 Kubernetes 的基本工作知识。
迷你库贝
Minikube 是一个用于在本地运行单节点 Kubernetes 集群的工具。它旨在快速启动并运行一个集群,这样您就可以开始在本地与 Kubernetes API 进行交互。
遵循官方的入门指南,安装 Minikube 以及:
- 一个虚拟机管理程序(像 VirtualBox 或 HyperKit )来管理虚拟机
- 在 Kubernetes 上部署和管理应用程序
如果你用的是 Mac,我们建议用 Homebrew 安装 Kubectl 和 Minikube:
`$ brew update $ brew install kubectl $ brew install minikube`
然后,启动组合仪表并拉起 Minikube 仪表盘:
`$ minikube config set vm-driver hyperkit
$ minikube start
$ minikube dashboard`
至此,我们将把注意力转向配置 TLS。
TLS 证书
TLS 将用于保护每个 Consul 成员之间的 RPC 通信。为此,我们将通过 CloudFlare 的 SSL 工具包 ( cfssl
和cfssljson
)创建一个证书颁发机构(CA)来签署证书,并向节点分发密钥。
如果你还没有安装 Go 就开始安装吧。
再说一遍,如果你用的是 Mac,安装 Go 最快的方法就是用自制软件:
`$ brew update $ brew install go`
安装后,创建一个工作区,配置 GOPATH 并将工作区的 bin 文件夹添加到您的系统路径:
`$ mkdir $HOME/go
$ export GOPATH=$HOME/go
$ export PATH=$PATH:$GOPATH/bin`
接下来,安装 SSL 工具包:
`$ go get -u github.com/cloudflare/cfssl/cmd/cfssl
$ go get -u github.com/cloudflare/cfssl/cmd/cfssljson`
创建名为“vault-consul-kubernetes”的新项目目录,并添加以下文件和文件夹:
`├── certs
│ ├── config
│ │ ├── ca-config.json
│ │ ├── ca-csr.json
│ │ ├── consul-csr.json
│ │ └── vault-csr.json
├── consul
└── vault`
ca-config.json :
`{ "signing": { "default": { "expiry": "87600h" }, "profiles": { "default": { "usages": [ "signing", "key encipherment", "server auth", "client auth" ], "expiry": "8760h" } } } }`
ca-csr.json
`{ "hosts": [ "cluster.local" ], "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "US", "ST": "Colorado", "L": "Denver" } ] }`
领事-csr.json :
`{ "CN": "server.dc1.cluster.local", "hosts": [ "server.dc1.cluster.local", "127.0.0.1" ], "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "US", "ST": "Colorado", "L": "Denver" } ] }`
【t0-CSR . JSON】:
`{ "hosts": [ "vault", "127.0.0.1" ], "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "US", "ST": "Colorado", "L": "Denver" } ] }`
有关这些文件的信息,请查看《带 TLS 加密的安全咨询代理通信指南》。
创建证书颁发机构:
`$ cfssl gencert -initca certs/config/ca-csr.json | cfssljson -bare certs/ca`
然后,为 Consul 创建一个私钥和一个 TLS 证书:
`$ cfssl gencert \
-ca=certs/ca.pem \
-ca-key=certs/ca-key.pem \
-config=certs/config/ca-config.json \
-profile=default \
certs/config/consul-csr.json | cfssljson -bare certs/consul`
对 Vault 执行相同的操作:
`$ cfssl gencert \
-ca=certs/ca.pem \
-ca-key=certs/ca-key.pem \
-config=certs/config/ca-config.json \
-profile=default \
certs/config/vault-csr.json | cfssljson -bare certs/vault`
现在,您应该会在“certs”目录中看到以下 PEM 文件:
- ca-key.pem
- ca.csr
- ca.pem
- 领事钥匙. pem
- 领事. csr
- 领事. pem
- vault-key.pem
- vault.csr
- vault.pem
领事
八卦加密密钥
Consul 使用八卦协议来广播加密信息,并发现加入集群的新成员。这需要一个共享密钥。要生成,首先安装 Consul 客户端 (Mac 用户要用 Brew 来做这个- brew install consul
),然后生成一个密钥并存储在一个环境变量中:
`$ export GOSSIP_ENCRYPTION_KEY=$(consul keygen)`
将密钥与 TLS 证书一起秘密存储:
`$ kubectl create secret generic consul \
--from-literal="gossip-encryption-key=${GOSSIP_ENCRYPTION_KEY}" \
--from-file=certs/ca.pem \
--from-file=certs/consul.pem \
--from-file=certs/consul-key.pem`
验证:
`$ kubectl describe secrets consul`
您应该看到:
`Name: consul
Namespace: default
Labels: <none>
Annotations: <none>
Type: Opaque
Data
====
consul.pem: 1359 bytes
gossip-encryption-key: 44 bytes
ca.pem: 1168 bytes
consul-key.pem: 1679 bytes`
配置
向“consul”添加一个名为 config.json 的新文件:
`{ "ca_file": "/etc/tls/ca.pem", "cert_file": "/etc/tls/consul.pem", "key_file": "/etc/tls/consul-key.pem", "verify_incoming": true, "verify_outgoing": true, "verify_server_hostname": true, "ports": { "https": 8443 } }`
通过将verify_incoming
、verify_outgoing
和verify_server_hostname
设置为true
,所有的 RPC 调用都必须被加密。
请务必查看咨询文档中的 RPC 加密与 TLS 指南,以了解有关这些选项的更多信息。
将此配置保存在配置映射中:
`$ kubectl create configmap consul --from-file=consul/config.json
$ kubectl describe configmap consul`
服务
定义一个无头服务——一个没有集群 IP-inconsul/Service . YAML的服务,在内部公开每个 Consul 成员:
`apiVersion: v1 kind: Service metadata: name: consul labels: name: consul spec: clusterIP: None ports: - name: http port: 8500 targetPort: 8500 - name: https port: 8443 targetPort: 8443 - name: rpc port: 8400 targetPort: 8400 - name: serflan-tcp protocol: "TCP" port: 8301 targetPort: 8301 - name: serflan-udp protocol: "UDP" port: 8301 targetPort: 8301 - name: serfwan-tcp protocol: "TCP" port: 8302 targetPort: 8302 - name: serfwan-udp protocol: "UDP" port: 8302 targetPort: 8302 - name: server port: 8300 targetPort: 8300 - name: consuldns port: 8600 targetPort: 8600 selector: app: consul`
创建服务:
`$ kubectl create -f consul/service.yaml
$ kubectl get service consul`
请确保在 StatefulSet 之前创建服务,因为由 StatefulSet 创建的 pod 将立即开始进行 DNS 查找以查找其他成员。
状态集
领事/statefulset.yaml :
`apiVersion: apps/v1 kind: StatefulSet metadata: name: consul spec: serviceName: consul replicas: 3 selector: matchLabels: app: consul template: metadata: labels: app: consul spec: securityContext: fsGroup: 1000 containers: - name: consul image: "consul:1.4.0" env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP - name: GOSSIP_ENCRYPTION_KEY valueFrom: secretKeyRef: name: consul key: gossip-encryption-key - name: NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace args: - "agent" - "-advertise=$(POD_IP)" - "-bind=0.0.0.0" - "-bootstrap-expect=3" - "-retry-join=consul-0.consul.$(NAMESPACE).svc.cluster.local" - "-retry-join=consul-1.consul.$(NAMESPACE).svc.cluster.local" - "-retry-join=consul-2.consul.$(NAMESPACE).svc.cluster.local" - "-client=0.0.0.0" - "-config-file=/consul/myconfig/config.json" - "-datacenter=dc1" - "-data-dir=/consul/data" - "-domain=cluster.local" - "-encrypt=$(GOSSIP_ENCRYPTION_KEY)" - "-server" - "-ui" - "-disable-host-node-id" volumeMounts: - name: config mountPath: /consul/myconfig - name: tls mountPath: /etc/tls lifecycle: preStop: exec: command: - /bin/sh - -c - consul leave ports: - containerPort: 8500 name: ui-port - containerPort: 8400 name: alt-port - containerPort: 53 name: udp-port - containerPort: 8443 name: https-port - containerPort: 8080 name: http-port - containerPort: 8301 name: serflan - containerPort: 8302 name: serfwan - containerPort: 8600 name: consuldns - containerPort: 8300 name: server volumes: - name: config configMap: name: consul - name: tls secret: secretName: consul`
部署三节点咨询集群:
`$ kubectl create -f consul/statefulset.yaml`
验证 pod 已启动并正在运行:
`$ kubectl get pods
NAME READY STATUS RESTARTS AGE
consul-0 1/1 Running 0 17s
consul-1 1/1 Running 0 7s
consul-2 1/1 Running 0 6s`
查看每个单元的日志,确保其中一个单元被选为领导者:
`$ kubectl logs consul-0
$ kubectl logs consul-1
$ kubectl logs consul-2`
示例日志:
`2021/04/27 21:24:36 [INFO] raft: Election won. Tally: 2
2021/04/27 21:24:36 [INFO] raft: Node at 172.17.0.7:8300 [Leader] entering Leader state
2021/04/27 21:24:36 [INFO] raft: Added peer a3ee83a0-e39b-f58b-e2d4-35a3689ff3d9, starting replication
2021/04/27 21:24:36 [INFO] consul: cluster leadership acquired
2021/04/27 21:24:36 [INFO] consul: New leader elected: consul-2
2021/04/27 21:24:36 [INFO] raft: Added peer f91746e3-881c-aebb-f8c5-b34bf37d3529, starting replication
2021/04/27 21:24:36 [WARN] raft: AppendEntries to {Voter a3ee83a0-e39b-f58b-e2d4-35a3689ff3d9 172.17.0.6:8300} rejected, sending older logs (next: 1)
2021/04/27 21:24:36 [INFO] raft: pipelining replication to peer {Voter a3ee83a0-e39b-f58b-e2d4-35a3689ff3d9 172.17.0.6:8300}
2021/04/27 21:24:36 [WARN] raft: AppendEntries to {Voter f91746e3-881c-aebb-f8c5-b34bf37d3529 172.17.0.5:8300} rejected, sending older logs (next: 1)
2021/04/27 21:24:36 [INFO] raft: pipelining replication to peer {Voter f91746e3-881c-aebb-f8c5-b34bf37d3529 172.17.0.5:8300}
2021/04/27 21:24:36 [INFO] consul: member 'consul-2' joined, marking health alive
2021/04/27 21:24:36 [INFO] consul: member 'consul-1' joined, marking health alive
2021/04/27 21:24:36 [INFO] consul: member 'consul-0' joined, marking health alive
2021/04/27 21:24:36 [INFO] agent: Synced node info`
将端口转发到本地机器:
`$ kubectl port-forward consul-1 8500:8500`
然后,在新的终端窗口中,确保所有成员都处于活动状态:
`$ consul members
Node Address Status Type Build Protocol DC Segment
consul-0 172.17.0.6:8301 alive server 1.4.0 2 dc1 <all>
consul-1 172.17.0.7:8301 alive server 1.4.0 2 dc1 <all>
consul-2 172.17.0.8:8301 alive server 1.4.0 2 dc1 <all>`
最后,您应该能够访问位于 http://localhost:8500 的 web 界面。
跳跃
接下来,让我们将 Vault 配置为在 Kubernetes 上运行。
秘密
将我们创建的保管库 TLS 证书存储在一个秘密位置:
`$ kubectl create secret generic vault \
--from-file=certs/ca.pem \
--from-file=certs/vault.pem \
--from-file=certs/vault-key.pem
$ kubectl describe secrets vault`
配置图
为 Vault 配置添加一个名为 vault/config.json 的新文件:
`{ "listener": { "tcp":{ "address": "127.0.0.1:8200", "tls_disable": 0, "tls_cert_file": "/etc/tls/vault.pem", "tls_key_file": "/etc/tls/vault-key.pem" } }, "storage": { "consul": { "address": "consul:8500", "path": "vault/", "disable_registration": "true", "ha_enabled": "true" } }, "ui": true }`
在这里,我们将 Vault 配置为使用 Consul 后端(支持高可用性),为 Vault 定义了 TCP 监听器,启用了 TLS ,添加了到 TLS 证书和私钥的路径,并启用了 Vault UI 。查看文档了解更多关于配置保险库的信息。
将此配置保存在配置映射中:
`$ kubectl create configmap vault --from-file=vault/config.json
$ kubectl describe configmap vault`
服务
vault/service.yaml :
`apiVersion: v1 kind: Service metadata: name: vault labels: app: vault spec: type: ClusterIP ports: - port: 8200 targetPort: 8200 protocol: TCP name: vault selector: app: vault`
创建:
`$ kubectl create -f vault/service.yaml
$ kubectl get service vault`
部署
vault/deployment.yaml :
`apiVersion: apps/v1 kind: Deployment metadata: name: vault labels: app: vault spec: replicas: 1 selector: matchLabels: app: vault template: metadata: labels: app: vault spec: containers: - name: vault command: ["vault", "server", "-config", "/vault/config/config.json"] image: "vault:0.11.5" imagePullPolicy: IfNotPresent securityContext: capabilities: add: - IPC_LOCK volumeMounts: - name: configurations mountPath: /vault/config/config.json subPath: config.json - name: vault mountPath: /etc/tls - name: consul-vault-agent image: "consul:1.4.0" env: - name: GOSSIP_ENCRYPTION_KEY valueFrom: secretKeyRef: name: consul key: gossip-encryption-key - name: NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace args: - "agent" - "-retry-join=consul-0.consul.$(NAMESPACE).svc.cluster.local" - "-retry-join=consul-1.consul.$(NAMESPACE).svc.cluster.local" - "-retry-join=consul-2.consul.$(NAMESPACE).svc.cluster.local" - "-encrypt=$(GOSSIP_ENCRYPTION_KEY)" - "-config-file=/consul/myconfig/config.json" - "-domain=cluster.local" - "-datacenter=dc1" - "-disable-host-node-id" - "-node=vault-1" volumeMounts: - name: config mountPath: /consul/myconfig - name: tls mountPath: /etc/tls volumes: - name: configurations configMap: name: vault - name: config configMap: name: consul - name: tls secret: secretName: consul - name: vault secret: secretName: vault`
部署保管库:
`$ kubectl apply -f vault/deployment.yaml`
要进行测试,请获取 Pod 名称,然后转发端口:
`$ kubectl get pods
NAME READY STATUS RESTARTS AGE
consul-0 1/1 Running 0 35m
consul-1 1/1 Running 0 35m
consul-2 1/1 Running 0 35m
vault-64754b559d-dw459 2/2 Running 0 7m
$ kubectl port-forward vault-64754b559d-dw459 8200:8200`
确保可以在 https://localhost:8200 查看 UI。
快速试验
在端口转发仍然打开的情况下,在新的终端窗口中,导航到项目目录并设置VAULT_ADDR
和VAULT_CACERT
环境变量:
`$ export VAULT_ADDR=https://127.0.0.1:8200
$ export VAULT_CACERT="certs/ca.pem"`
在本地安装保险库客户端,如果您还没有的话,然后用一个密钥初始化保险库:
`$ vault operator init -key-shares=1 -key-threshold=1`
记下解封密钥和初始根令牌。
`Unseal Key 1: iejZsVPrDFPbQL+JUW5HGMub9tlAwSSr7bR5NuAX9pg=
Initial Root Token: 85kVUa6mxr2VFawubh1YFG6t
Vault initialized with 1 key shares and a key threshold of 1. Please securely
distribute the key shares printed above. When the Vault is re-sealed,
restarted, or stopped, you must supply at least 1 of these keys to unseal it
before it can start servicing requests.
Vault does not store the generated master key. Without at least 1 key to
reconstruct the master key, Vault will remain permanently sealed!
It is possible to generate new unseal keys, provided you have a quorum of
existing unseal keys shares. See "vault operator rekey" for more information.`
虚构的
`$ vault operator unseal
Unseal Key (will be hidden):
Key Value
--- -----
Seal Type shamir
Initialized true
Sealed false
Total Shares 1
Threshold 1
Version 0.11.5
Cluster Name vault-cluster-2c64d090
Cluster ID 42db2c78-938b-fe5c-aa15-f70be43a5cb4
HA Enabled true
HA Cluster n/a
HA Mode standby
Active Node Address <none>`
使用根令牌进行身份验证:
`$ vault login
Token (will be hidden):
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.
Key Value
--- -----
token 85kVUa6mxr2VFawubh1YFG6t
token_accessor 8hGliUJJeM8iijbiSzqiH49o
token_duration ∞
token_renewable false
token_policies ["root"]
identity_policies []
policies ["root"]`
创建新的秘密:
`$ vault kv put secret/precious foo=bar
Success! Data written to: secret/precious`
阅读:
`$ vault kv get secret/precious
=== Data ===
Key Value
--- -----
foo bar`
完成后关闭集群。
自动化脚本
最后,让我们创建一个快速脚本来自动化资源调配过程:
- 生成八卦加密密钥
- 创建一个秘密来存储 Gossip 密钥和 TLS 证书
- 将咨询配置存储在配置映射中
- 创建咨询服务和状态集
- 创建一个密码来存储保管库 TLS 证书
- 将存储库配置存储在配置映射中
- 创建 Vault 服务和部署
- 将端口转发添加到端口 8200 的存储区
将名为 create.sh 的新文件添加到项目根目录:
`#!/bin/bash
echo "Generating the Gossip encryption key..."
export GOSSIP_ENCRYPTION_KEY=$(consul keygen)
echo "Creating the Consul Secret to store the Gossip key and the TLS certificates..."
kubectl create secret generic consul \
--from-literal="gossip-encryption-key=${GOSSIP_ENCRYPTION_KEY}" \
--from-file=certs/ca.pem \
--from-file=certs/consul.pem \
--from-file=certs/consul-key.pem
echo "Storing the Consul config in a ConfigMap..."
kubectl create configmap consul --from-file=consul/config.json
echo "Creating the Consul Service..."
kubectl create -f consul/service.yaml
echo "Creating the Consul StatefulSet..."
kubectl create -f consul/statefulset.yaml
echo "Creating a Secret to store the Vault TLS certificates..."
kubectl create secret generic vault \
--from-file=certs/ca.pem \
--from-file=certs/vault.pem \
--from-file=certs/vault-key.pem
echo "Storing the Vault config in a ConfigMap..."
kubectl create configmap vault --from-file=vault/config.json
echo "Creating the Vault Service..."
kubectl create -f vault/service.yaml
echo "Creating the Vault Deployment..."
kubectl apply -f vault/deployment.yaml
echo "All done! Forwarding port 8200..."
POD=$(kubectl get pods -o=name | grep vault | sed "s/^.\{4\}//")
while true; do
STATUS=$(kubectl get pods ${POD} -o jsonpath="{.status.phase}")
if [ "$STATUS" == "Running" ]; then
break
else
echo "Pod status is: ${STATUS}"
sleep 5
fi
done
kubectl port-forward $POD 8200:8200`
在测试之前,确保 Minikube 启动并创建 TLS 证书。
在新的终端窗口中,导航到项目目录并运行:
`$ export VAULT_ADDR=https://127.0.0.1:8200
$ export VAULT_CACERT="certs/ca.pem"`
检查状态:
你可以在金库-领事-kubernetes 回购中找到最终代码。
使用 Flask、Redis Queue 和 Amazon SES 发送确认电子邮件
原文:https://testdriven.io/blog/sending-confirmation-emails-with-flask-rq-and-ses/
对于大多数 web 应用程序,在新用户注册后,确认用户提供了他们有权访问的有效电子邮件地址是很重要的。这不仅有助于防止垃圾邮件发送者创建假帐户,而且还为您的应用程序提供了额外的安全层。
例如,在没有首先验证电子邮件地址的情况下,您永远不要发送密码重置电子邮件。假设一个新用户在注册流程中输入了错误的电子邮件地址,并且该用户试图发送一封重置密码的电子邮件。最好的情况是,用户根本收不到邮件。在最坏的情况下,密码重置电子邮件将转到一个有效的电子邮件地址,不属于该用户,他们的帐户就很容易受到威胁。
本教程着眼于如何向 Flask、Redis Queue (RQ)和 Amazon SES (SES)的新注册用户发送确认电子邮件。
虽然本教程使用了 RQ 和 ses,但重要的是要关注本教程中的概念和模式,而不是所使用的特定工具和技术。通过使用不同的任务队列(如 Celery)和/或事务性电子邮件服务(如 SendGrid 或 Mailgun)来检查你的理解。
目标
完成本教程后,您将能够:
- 讨论整个客户端/服务器电子邮件确认工作流程。
- 描述什么是电子邮件确认,以及为什么你想在你的申请注册流程中使用它。
- 将 Redis 队列集成到 Flask 应用程序中,并创建任务。
- 用容器装烧瓶并用码头工人重新分发。
- 使用单独的工作进程在后台运行长时间运行的任务。
- 使用 itsdangerous 模块对令牌进行编码和解码。
- 通过 Boto3 与 AWS API 交互。
- 使用亚马逊简单电子邮件服务(SES)发送交易电子邮件。
项目设置
要按照本教程编写代码,请克隆基础项目:
`$ git clone https://github.com/testdrivenio/flask-ses-rq --branch base --single-branch
$ cd flask-ses-rq`
快速检查代码和整个项目结构:
`├── Dockerfile
├── docker-compose.yml
├── manage.py
├── project
│ ├── __init__.py
│ ├── client
│ │ ├── static
│ │ │ ├── main.css
│ │ │ └── main.js
│ │ └── templates
│ │ ├── _base.html
│ │ ├── footer.html
│ │ └── home.html
│ ├── db
│ │ ├── Dockerfile
│ │ └── create.sql
│ ├── server
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── main
│ │ │ ├── __init__.py
│ │ │ ├── forms.py
│ │ │ └── views.py
│ │ └── models.py
│ └── tests
│ ├── __init__.py
│ ├── base.py
│ ├── helpers.py
│ ├── test__config.py
│ └── test_main.py
└── requirements.txt`
然后,启动应用程序:
`$ docker-compose up -d --build`
本教程使用 Docker 版本 20.10.10。
创建数据库表:
`$ docker-compose run users python manage.py create_db`
在浏览器中导航至 http://localhost:5003 。您应该看到:
确保您可以添加新用户:
运行测试:
`$ docker-compose run users python manage.py test
----------------------------------------------------------------------
Ran 8 tests in 0.225s
OK`
工作流程
以下是我们将使用的工作流程:
- 一个新用户提交注册表单,该表单向服务器端发送 POST 请求。
- 在 Flask 视图中,在新用户成功添加到数据库之后,一个新任务被添加到队列中,并且一个响应被发送回最终用户,指示他们需要通过电子邮件确认他们的注册。
- 在后台,一个工作进程选择任务,生成一个惟一的链接,并向 Amazon SES 发送一个发送确认电子邮件的请求。
- 然后,最终用户可以通过单击唯一链接,从自己的邮箱中确认电子邮件。
- 当用户单击链接时,GET 请求被发送到服务器端,服务器端更新数据库中的用户记录。
如果你试图将电子邮件确认整合到现有的应用程序中,上述工作流程将根据你的应用程序的流程而有所不同。在学习本教程时,请记住这一点。
最终应用程序正在运行:
重复队列
首先,让我们连接任务队列!
码头工人
首先启动两个新流程:Redis 和一个 worker。像这样更新 docker-compose.yml 文件:
`version: '3.8' services: users: build: . image: users container_name: users ports: - 5003:5000 command: python manage.py run -h 0.0.0.0 volumes: - .:/usr/src/app environment: - FLASK_DEBUG=1 - APP_SETTINGS=project.server.config.DevelopmentConfig - DATABASE_URL=postgresql://postgres:[[email protected]](/cdn-cgi/l/email-protection):5432/users_dev - DATABASE_TEST_URL=postgresql://postgres:[[email protected]](/cdn-cgi/l/email-protection):5432/users_test - SECRET_KEY=my_precious depends_on: - users-db - redis users-db: container_name: users-db build: context: ./project/db dockerfile: Dockerfile expose: - 5432 environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres worker: image: users command: python manage.py run_worker volumes: - .:/usr/src/app environment: - FLASK_DEBUG=1 - APP_SETTINGS=project.server.config.DevelopmentConfig - DATABASE_URL=postgresql://postgres:[[email protected]](/cdn-cgi/l/email-protection):5432/users_dev - DATABASE_TEST_URL=postgresql://postgres:[[email protected]](/cdn-cgi/l/email-protection):5432/users_test - SECRET_KEY=my_precious depends_on: - users-db - redis redis: image: redis:6-alpine`
将依赖项添加到 requirements.txt :
工作
在“project/server/main”中的一个名为 tasks.py 的文件中添加一个新任务:
`# project/server/main/tasks.py
import time
from project.server import db
from project.server.models import User
def send_email(email):
time.sleep(10) # simulate long-running process
user = User.query.filter_by(email=email).first()
user.email_sent = True
db.session.commit()
return True`
这里,我们模拟了一个长时间运行的流程,然后将User
模型中的email_sent
字段更新为True
。我们将很快用发送电子邮件的实际功能取代time.sleep(10)
。
email_sent
设置为True
后,用户技术上注册但“未确认”。此时,允许用户做什么?换句话说,那个用户对你的应用程序有完全的访问权限,某种形式的受限访问权限,还是根本没有访问权限?想想你会如何在你的应用程序中处理这个问题。
更新视图以连接到 Redis 并对任务进行排队:
`@main_blueprint.route('/', methods=['GET', 'POST'])
def home():
form = RegisterForm(request.form)
if request.method == 'POST':
if form.validate_on_submit():
try:
user = User(email=form.email.data)
db.session.add(user)
db.session.commit()
redis_url = current_app.config['REDIS_URL']
with Connection(redis.from_url(redis_url)):
q = Queue()
q.enqueue(send_email, user.email)
flash('Thank you for registering.', 'success')
return redirect(url_for("main.home"))
except IntegrityError:
db.session.rollback()
flash('Sorry. That email already exists.', 'danger')
users = User.query.all()
return render_template('home.html', form=form, users=users)`
更新导入:
`import redis
from flask import render_template, Blueprint, url_for, \
redirect, flash, request, current_app
from rq import Queue, Connection
from sqlalchemy.exc import IntegrityError
from project.server import db
from project.server.models import User
from project.server.main.forms import RegisterForm
from project.server.main.tasks import send_email`
将配置添加到 project/server/config.py 中的BaseConfig
:
`class BaseConfig(object):
"""Base configuration."""
SECRET_KEY = os.environ.get('SECRET_KEY')
SQLALCHEMY_TRACK_MODIFICATIONS = False
WTF_CSRF_ENABLED = False
REDIS_URL = 'redis://redis:6379/0'
QUEUES = ['default']`
注意,我们在REDIS_URL
中引用了redis
服务,在 docker-compose.yml 中定义,而不是localhost
。查看 Docker Compose 文档以获得更多关于通过主机名别名连接到其他服务的信息。
工人
接下来,让我们向 manage.py 添加一个自定义 CLI 命令来触发 worker 进程,该进程用于处理我们添加到队列中的任务:
`@cli.command('run_worker')
def run_worker():
redis_url = app.config['REDIS_URL']
redis_connection = redis.from_url(redis_url)
with Connection(redis_connection):
worker = Worker(app.config['QUEUES'])
worker.work()`
不要忘记进口:
`import redis
from rq import Connection, Worker`
试验
旋转新容器:
`$ docker-compose up -d --build`
要触发新任务,请注册一个新用户。Confirm Email Sent?
应该是False
:
然后,十秒钟后刷新页面。Confirm Email Sent?
现在应该是True
,因为任务已经完成,数据库也已经更新。
电子邮件确认
接下来,让我们从模板开始添加确认电子邮件地址的逻辑。
电子邮件模板
我们可以使用 Jinja 在服务器上生成模板。
`Thanks for signing up. Please follow the link to activate your account.
{{ confirm_url }} Cheers!`
将上述文本保存到“项目/客户端/模板”中一个名为 email.txt 的新文件中。
目前,我们只会发送一封纯文本电子邮件。你可以随意添加 HTML(基本的和/或丰富的)。
唯一 URL
接下来,让我们添加一些辅助函数来编码和解码令牌,这将为生成唯一的确认 URL 奠定基础。
将名为 utils.py 的新文件添加到“项目/服务器/main”中:
`# project/server/main/utils.py
from itsdangerous import URLSafeTimedSerializer
from flask import current_app, url_for
def encode_token(email):
serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
return serializer.dumps(email, salt='email-confirm-salt')
def decode_token(token, expiration=3600):
serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
try:
email = serializer.loads(
token,
salt='email-confirm-salt',
max_age=expiration
)
return email
except Exception as e:
return False
def generate_url(endpoint, token):
return url_for(endpoint, token=token, _external=True)`
这里发生了什么事?
encode_token
利用的危险包中的URLSafeTimedSerializer
类来编码令牌中的电子邮件地址和时间戳。decode_token
然后解码令牌并返回电子邮件地址,只要令牌不超过 3600 秒(一小时)。generate_url
接受一个端点和一个编码的令牌,然后返回一个唯一的 URL。(没错,这是单行函数!这使得测试更加容易。)
因为默认情况下,
url_for
创建相对 URL,所以我们将_external
设置为True
来生成绝对 URL。如果这是在 Flask 请求上下文之外创建的,您需要在应用配置和中定义一个SERVER_NAME
来提供对应用上下文的访问,以使用绝对 URL。一旦设置了一个SERVER_NAME
,Flask 就只能服务来自那个域的请求。查看以下问题了解更多信息。
让我们添加一些快速测试,以确保令牌的编码和解码以及惟一 URL 的生成按预期进行。
test_utils.py :
`# project/server/tests/test_utils.py
import time
import unittest
from base import BaseTestCase
from project.server.main.utils import encode_token, decode_token, generate_url
from project.server.models import User
class TestUtils(BaseTestCase):
def test_verify_token(self):
# Ensure encode and decode behave correctly.
token = encode_token('[[email protected]](/cdn-cgi/l/email-protection)')
email = decode_token(token)
self.assertEqual(email, '[[email protected]](/cdn-cgi/l/email-protection)')
def test_verify_invalid_token(self):
# Ensure encode and decode behave correctly when token is invalid.
token = 'invalid'
email = decode_token(token)
self.assertEqual(email, False)
def test_verify_expired_token(self):
# Ensure encode and decode behave correctly when token has expired.
token = encode_token('[[email protected]](/cdn-cgi/l/email-protection)')
time.sleep(1)
email = decode_token(token, 0)
self.assertEqual(email, False)
def test_token_is_unique(self):
# Ensure tokens are unique.
token1 = encode_token('[[email protected]](/cdn-cgi/l/email-protection)')
token2 = encode_token('[[email protected]](/cdn-cgi/l/email-protection)')
self.assertNotEqual(token1, token2)
def test_generate_url(self):
# Ensure generate_url behaves as expected.
token = encode_token('[[email protected]](/cdn-cgi/l/email-protection)')
url = generate_url('main.home', token)
url_token = url.split('=')[1]
self.assertEqual(token, url_token)
email = decode_token(url_token)
self.assertEqual(email, '[[email protected]](/cdn-cgi/l/email-protection)')
if __name__ == '__main__':
unittest.main()`
运行测试:
`$ docker-compose run users python manage.py test
----------------------------------------------------------------------
Ran 13 tests in 1.305s
OK`
我们错过什么测试了吗?现在添加它们。你会如何模拟使用
sleep(1)
的测试?看看冰枪!
接下来,对视图进行一些更新:
`@main_blueprint.route('/', methods=['GET', 'POST'])
def home():
form = RegisterForm(request.form)
if request.method == 'POST':
if form.validate_on_submit():
try:
# add user to the db
user = User(email=form.email.data)
db.session.add(user)
db.session.commit()
# generate token, confirm url, and template
token = encode_token(user.email)
confirm_url = generate_url('main.confirm_email', token)
body = render_template('email.txt', confirm_url=confirm_url)
# enqueue
redis_url = current_app.config['REDIS_URL']
with Connection(redis.from_url(redis_url)):
q = Queue()
q.enqueue(send_email, user.email, body)
flash('Thank you for registering.', 'success')
return redirect(url_for("main.home"))
except IntegrityError:
db.session.rollback()
flash('Sorry. That email already exists.', 'danger')
users = User.query.all()
return render_template('home.html', form=form, users=users)`
确保导入encode_token
和generate_url
:
`from project.server.main.utils import encode_token, generate_url`
因此,在将用户添加到数据库之后,我们创建了一个令牌、一个惟一的 URL(我们仍然需要为其创建视图)和一个模板。
最后,将body
作为参数添加到send_email
中:
`def send_email(email, body):
time.sleep(10) # simulate long-running process
user = User.query.filter_by(email=email).first()
user.email_sent = True
db.session.commit()
return True`
我们很快就会用到它。
视角
接下来,让我们添加confirm_email
视图来处理令牌,如果合适的话,更新用户模型:
`@main_blueprint.route('/confirm/<token>')
def confirm_email(token):
email = decode_token(token)
if not email:
flash('The confirmation link is invalid or has expired.', 'danger')
return redirect(url_for('main.home'))
user = User.query.filter_by(email=email).first()
if user.confirmed:
flash('Account already confirmed.', 'success')
return redirect(url_for('main.home'))
user.confirmed = True
db.session.add(user)
db.session.commit()
flash('You have confirmed your account. Thanks!', 'success')
return redirect(url_for('main.home'))`
导入decode_token
:
`from project.server.main.utils import encode_token, generate_url, decode_token`
因此,如果解码成功,数据库记录的confirmed
字段将更新为True
,用户将通过一条成功消息被重定向回主页。
试验
要手动测试,首先关闭容器和体积。然后,旋转容器,创建数据库表,并打开worker
的 Docker 日志:
`$ docker-compose down -v
$ docker-compose up -d --build
$ docker-compose run users python manage.py create_db
$ docker-compose logs -f worker`
然后,从浏览器中添加一个新的电子邮件地址。您应该看到任务成功启动和完成:
`21:16:49 default: project.server.main.tasks.send_email(
'[[email protected]](/cdn-cgi/l/email-protection)',
'Thanks for signing up. Please follow the link to activate your account.\nh...
) (af8974f4-c4b7-4db1-ba15-7e2bc57ee058)
21:16:59 default: Job OK (af8974f4-c4b7-4db1-ba15-7e2bc57ee058)
21:16:59 Result is kept for 500 seconds`
亚马逊的 SES
首先,你为什么想通过 Gmail 或你自己的电子邮件服务器使用交易型电子邮件服务(比如亚马逊 SES 、 Mailchimp 交易型电子邮件(之前的 Mandrill)或 Mailgun )。
- 速率限制:电子邮件服务提供商,如 Gmail、Yahoo、Outlook,有每小时或每天发送邮件的限制。交易型电子邮件服务提供商也有限制,但是要高得多。
- 可送达性:大多数电子邮件服务提供商不允许来自未知 IP 地址的邮件。此类电子邮件被标记为垃圾邮件,通常不会到达收件箱。因此,如果你从你自己的邮件服务器发送交易邮件,在一个共享的服务器上,这些邮件很可能永远不会被你的用户看到。事务性电子邮件服务与互联网服务提供商和电子邮件服务提供商建立关系,以确保电子邮件顺利及时地送达。
- 分析:交易电子邮件服务提供详细的统计和分析,帮助您提高电子邮件的打开率和点击率。
亚马逊 SES 是一项经济高效的电子邮件服务,旨在发送批量和交易电子邮件。电子邮件可以通过简单邮件传输协议(SMTP)界面直接从 SES 控制台发送,也可以通过 API 发送。
在本教程中,我们将使用基于 Python 的 AWS SDKboto 3来调用 API。
设置
在使用 SES 发送电子邮件之前,您必须首先验证您拥有您希望发送的电子邮件地址。导航到亚马逊 SES ,点击侧边栏中的“已验证身份”,然后点击“创建身份”按钮。
在“身份类型”下,选择“电子邮件地址”。输入您想要使用的电子邮件,然后单击“创建身份”。
然后,点击电子邮件收件箱中的验证链接后,您应该会看到您的电子邮件在 SES 上得到验证。
为了帮助防止欺诈,新账户会被自动置于沙盒模式,在这种模式下,你只能向你亲自向亚马逊核实过的地址发送电子邮件。幸运的是,这足以让我们将所有东西连接在一起。
你必须向 Amazon 请求退出沙盒模式。这可能需要一两天的时间,所以尽快开始。查看走出亚马逊 SES 沙盒了解更多信息。
电子邮件
回到代码中,将boto3
添加到需求文件中:
更新send_email
:
`def send_email(email, body):
# time.sleep(10) # simulate long-running process
ses = boto3.client(
'ses',
region_name=os.getenv('SES_REGION'),
aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID'),
aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY')
)
ses.send_email(
Source=os.getenv('SES_EMAIL_SOURCE'),
Destination={'ToAddresses': [email]},
Message={
'Subject': {'Data': 'Confirm Your Account'},
'Body': {
'Text': {'Data': body}
}
}
)
user = User.query.filter_by(email=email).first()
user.email_sent = True
db.session.commit()
return True`
这里,我们创建了一个新的 SES 客户机资源,然后尝试发送一封电子邮件。
导入os
和boto3
:
更新 docker-compose.yml 中worker
的环境变量,确保更新值:
`- SES_REGION=us-east-2 - SES_EMAIL_SOURCE=your_email - AWS_ACCESS_KEY_ID=your_access_key_id - AWS_SECRET_ACCESS_KEY=your_secret_access_key`
值得注意的是,默认情况下,
Boto3
将检查AWS_ACCESS_KEY_ID
和AWS_SECRET_ACCESS_KEY
环境变量以获取凭证。因此,在创建 SES 客户端资源时,我们不需要显式地传递它们。换句话说,只要定义了这些环境变量,我们就可以简化代码:ses = boto3.client('ses', region_name=os.getenv('SES_REGION'))
关于这方面的更多信息,请查看官方 Boto3 文档。
试验
更新容器:
`$ docker-compose up -d --build`
然后,从浏览器注册一个用户,确保使用与 SES 相同的电子邮件。您应该会在收件箱中看到一封确认邮件。点击链接,您应该会被重定向回 http://localhost:5003 。
记住:如果你仍然处于沙盒模式,你只能发送电子邮件到验证过的地址。如果您尝试向未经验证的地址发送电子邮件,任务将会失败:
`raise error_class(parsed_response, operation_name)
botocore.errorfactory.MessageRejected: An error occurred (MessageRejected) when calling the SendEmail operation:
Email address is not verified. The following identities failed the check in region US-EAST-2: [[email protected]](/cdn-cgi/l/email-protection)`
此外,由于您可能使用单个电子邮件地址进行测试,您可能希望删除模型上的唯一约束。否则,您需要在两次测试之间从数据库中删除用户。
`email = db.Column(db.String(255), unique=False, nullable=False)`
虽然惟一的约束是False
,但是您可能还想确保来自confirm_email
视图的以下代码有效:
`if user.confirmed:
flash('Account already confirmed.', 'success')
return redirect(url_for('main.home'))`
想想如何测试这个?
- 在同一个邮箱下注册两个用户
- 确认其中一个
- 从数据库中删除未确认的用户
- 尝试确认其他用户
您应该看到:
完成测试后,不要忘记将唯一约束添加回去!
有关我们通过
Boto3
在 SES 上发送电子邮件的过程的更多信息,请查看使用用于 Python (Boto) 的 AWS SDK 发送电子邮件指南。
结论
在本教程中,我们详细介绍了如何发送确认电子邮件,新注册的用户必须在他们的帐户被完全激活之前点击。
寻找一些挑战?
- Redis 队列:添加 RQ Dashboard,一个基于 web 的 Redis 队列监控系统。参见带 Flask 和 Redis 队列的异步任务了解更多信息。
- 电子邮件模板:如上所述,与纯文本电子邮件模板一起,生成确认电子邮件模板的 HTML 版本。
- 工具:不喜欢我们正在使用的工具?把 Redis 队列换成芹菜,或者把 SES 换成 Mailgun。
- 重新发送确认电子邮件:尝试将重新发送确认电子邮件的功能整合到此流程中。
- 密码重置:同样,尝试将通过电子邮件重置密码添加到这个流程中。
- 验证码:想要多一层安全保障?添加验证码或双因素认证(通过短信)。
- 处理失败:如果出现异常会怎样?如果确认邮件发送失败,你可能会失去这个潜在用户。因此,您可能希望设置 Redis 队列的自动重试策略,以防失败。
和往常一样,你可以在 repo 中找到代码。干杯!
使用 Django 设置条带连接
原文:https://testdriven.io/blog/setting-up-stripe-connect-with-django/
Stripe Connect 是一项旨在代表他人处理和管理支付的服务。它被需要向多方支付的市场和平台(如优步、Shopify、Kickstarter 和 Airbnb)使用。我们在 TestDriven.io 使用它来驱动我们的支付平台,这样我们就可以轻松地向内容创作者和分支机构支付费用。
本教程着眼于如何将 Stripe Connect 集成到 Django 应用程序中。
学习目标
学完本教程后,您应该能够:
- 解释什么是条带连接,以及为什么您可能需要使用它
- 描述条带连接帐户类型之间的相似性和差异
- 将 Stripe Connect 集成到现有的 Django 应用程序中
- 使用类似 Oauth 的流程将 Stripe 帐户链接到 Django 应用程序
- 解释直接费用和目的地费用的区别
条带连接帐户
使用 Stripe Connect,您首先需要决定您希望在您的平台上使用的用户帐户的类型:
“平台”是指你的市场网络应用,而“用户”是指通过你的平台销售商品或服务而获得报酬的人。
对于标准帐户和快速帐户,您平台上的用户将经历一个类似 OAuth 的流程,他们将被发送到 Stripe,创建或链接他们的 Stripe 帐户,然后被重定向回您的平台。这可能具有相当大的破坏性。用户还需要维护两个帐户——一个用于您的平台,一个用于 Stripe——这并不理想。如果你希望每个月都有大量的用户,这可能是行不通的。另一方面,如果你刚刚开始并想快速上手,坚持使用标准账户。从那里开始。如果您发现您需要对入职体验进行更多的控制,以使其更加无缝,那么您可能需要切换到与 Express 或 Custom 帐户集成。
除了 UX,对于快递和定制账户,你(平台)最终要对欺诈负责,并且必须处理有争议的交易。
作为参考, TestDriven.io 平台使用标准账户和快捷账户,具体取决于交易类型和参与方数量。
在选择要合作的客户类型时,问问自己:
- 入职体验需要多无缝?
- 谁应该处理欺诈和支付纠纷?
在本教程中,我们将坚持使用标准帐户。更多信息,请查看 Stripe 的选择方法和最佳实践指南。
工作流程
同样,对于标准(和快速)账户,用户通过类似于 OAuth 的流程来连接他们的 Stripe 账户:
- 平台上经过身份验证的用户单击一个链接,将他们带到条带化
- 然后,他们通过登录现有帐户或创建新帐户来连接条带帐户
- 连接后,用户会通过授权码被重定向回您的平台
- 然后,您请求使用该代码进行条带化,以便获得处理支付所需的信息
初始设置
首先,克隆出django-stripe-connectrepo,然后检查主分支的 v1 标记:
`$ git clone https://github.com/testdrivenio/django-stripe-connect --branch v1 --single-branch
$ cd django-stripe-connect
$ git checkout tags/v1 -b master`
创建虚拟环境并安装依赖项:
`$ pipenv shell
$ pipenv install`
应用迁移,创建超级用户,并将设备添加到数据库:
`$ python manage.py migrate
$ python manage.py createsuperuser
$ python manage.py loaddata fixtures/users.json
$ python manage.py loaddata fixtures/courses.json`
运行服务器:
`$ python manage.py runserver`
在 http://localhost:8000/ 您应该看到:
确保您能够以超级用户身份登录:
尝试以买家和卖家的双重身份登录。
买家:
卖家:
本质上,这个示例应用程序类似于 TestDriven.io 平台——用户可以创建和销售课程。 CustomUser 模型扩展了内置的用户模型,创建了卖家和买家。卖家可以买卖课程,而买家只能购买课程。添加新用户时,默认情况下他们是买家。超级用户可以更改用户的状态。
在继续之前,快速浏览一下项目结构:
`├── Pipfile
├── Pipfile.lock
├── apps
│ ├── courses
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── migrations
│ │ ├── models.py
│ │ ├── tests.py
│ │ ├── urls.py
│ │ └── views.py
│ └── users
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── forms.py
│ ├── managers.py
│ ├── migrations
│ ├── models.py
│ ├── signals.py
│ ├── tests.py
│ └── views.py
├── fixtures
│ ├── courses.json
│ └── users.json
├── manage.py
├── my_project
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── static
│ └── bulma.min.css
└── templates
├── _base.html
├── courses
│ ├── course_detail.html
│ └── course_list.html
├── home.html
├── login.html
└── nav.html`
配置条带
Stripe Checkout 已经预配置好,还有 Stripe Python 库。为了处理支付,创建一个 Stripe 帐户(如果您还没有),并将您的测试机密和测试可发布密钥添加到 settings.py 文件的底部:
`STRIPE_PUBLISHABLE_KEY = '<your test publishable key here>'
STRIPE_SECRET_KEY = '<your test secret key here>'`
确保您可以处理费用。首先,使用买家帐户登录:
然后,购买课程:
您应该会在“支付”下的 Stripe 仪表盘上看到费用:
需要帮助吗?参考 Django 条纹教程博文中的添加条纹部分。
要使用 Stripe Connect 注册您的平台,请点击 Stripe 仪表盘左侧栏中的“Connect ”:
然后,单击“开始”按钮。注册后,点击“设置”链接,获取您的测试客户端 ID:
将此添加到 settings.py 的底部:
`STRIPE_CONNECT_CLIENT_ID = '<your test connect client id here>'`
回到仪表板,使用http://localhost:8000/users/oauth/callback
作为重定向 URI。也可以随时更新“品牌”部分:
连接帐户
接下来,让我们在主页上添加一个“连接 Stripe 帐户”按钮的链接,该链接将用户发送到 Stripe,以便他们可以链接他们的帐户。
重定向至条带
将视图添加到 apps/users/views.py :
`import urllib
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.views import View
from django.conf import settings
from django.shortcuts import redirect
class StripeAuthorizeView(View):
def get(self, request):
if not self.request.user.is_authenticated:
return HttpResponseRedirect(reverse('login'))
url = 'https://connect.stripe.com/oauth/authorize'
params = {
'response_type': 'code',
'scope': 'read_write',
'client_id': settings.STRIPE_CONNECT_CLIENT_ID,
'redirect_uri': f'http://localhost:8000/users/oauth/callback'
}
url = f'{url}?{urllib.parse.urlencode(params)}'
return redirect(url)`
如果用户通过了身份验证,我们从response_type
、scope
、client_id
和redirect_uri
创建 OAuth 链接,然后通过授权 URL 将它们重定向到 Stripe。
scope
可以是read_only
或者read_write
:
- 当平台只需要视图访问时,使用
read_only
。- 当平台需要查看、创建、修改权限时,使用
read_write
来代表关联账户进行收费。如需了解更多信息,请查看平台在连接条纹账户时会获得哪些权限?
更新 my_project/urls.py 中的项目级 URL:
`urlpatterns = [
path('', TemplateView.as_view(template_name='home.html'), name='home'),
path('login/', LoginView.as_view(template_name='login.html'), name='login'),
path('logout/', LogoutView.as_view(), {'next_page': settings.LOGOUT_REDIRECT_URL}, name='logout'),
path('courses/', include('apps.courses.urls')),
path('users/', include('apps.users.urls')),
path('admin/', admin.site.urls),
]`
然后,通过向“应用/用户”添加一个 urls.py 文件来添加应用级 URL:
`from django.urls import path
from .views import StripeAuthorizeView
urlpatterns = [
path('authorize/', StripeAuthorizeView.as_view(), name='authorize'),
]`
将href
添加到home.html模板中的“连接条纹账户”按钮:
`<a href="{% url 'authorize' %}" class="button is-info">Connect Stripe Account</a>`
要进行测试,运行 Django 服务器,然后使用卖家帐户登录:
当您点按“连接条带帐户”时,请确保您被重定向到条带:
暂时不要做任何事情,因为我们仍然需要设置重定向视图。
重定向回来
向 apps/users/views.py 添加新视图
`class StripeAuthorizeCallbackView(View):
def get(self, request):
code = request.GET.get('code')
if code:
data = {
'client_secret': settings.STRIPE_SECRET_KEY,
'grant_type': 'authorization_code',
'client_id': settings.STRIPE_CONNECT_CLIENT_ID,
'code': code
}
url = 'https://connect.stripe.com/oauth/token'
resp = requests.post(url, params=data)
print(resp.json())
url = reverse('home')
response = redirect(url)
return response`
连接 Stripe 账户后,用户被重定向回平台,在那里我们将使用提供的授权码调用访问令牌 URL 来获得用户的 Stripe 凭证。
安装请求库:
`$ pipenv install requests==2.21.0`
将导入添加到 apps/users/views.py :
将回调 URL 添加到 apps/users/urls.py
`from django.urls import path
from .views import StripeAuthorizeView, StripeAuthorizeCallbackView
urlpatterns = [
path('authorize/', StripeAuthorizeView.as_view(), name='authorize'),
path('oauth/callback/', StripeAuthorizeCallbackView.as_view(), name='authorize_callback'),
]`
接下来,创建一个用于测试目的的新 Stripe 帐户,您将使用它来连接到平台帐户。完成后,您可以测试完整的 OAuth 过程:
- 在匿名或私人浏览器窗口中导航至 http://localhost:8000/
- 用
[[email protected]](/cdn-cgi/l/email-protection)
/justatest
登录平台 - 单击“连接条带帐户”
- 使用新的条带帐户登录
- 点击“连接我的 Stripe 账户”按钮,这将把你重新定向到 Django 应用程序
在您的终端中,您应该会看到来自print(resp.json())
的输出:
`{
'access_token': 'sk_test_nKM42TMNPm6M3c98U07abQss',
'livemode': False,
'refresh_token': 'rt_5QhvTKUgPuFF1EIRsHV4b4DtTxDZgMQiQRvOoMewQptbyfRc',
'token_type': 'bearer',
'stripe_publishable_key': 'pk_test_8iD6CpftCZLTp40k1pAl22hp',
'stripe_user_id': 'acct_i3qMgnSiH35BL8aU',
'scope': 'read_write'
}`
我们现在可以将access_token
和stripe_user_id
添加到Seller
模型中:
`class StripeAuthorizeCallbackView(View):
def get(self, request):
code = request.GET.get('code')
if code:
data = {
'client_secret': settings.STRIPE_SECRET_KEY,
'grant_type': 'authorization_code',
'client_id': settings.STRIPE_CONNECT_CLIENT_ID,
'code': code
}
url = 'https://connect.stripe.com/oauth/token'
resp = requests.post(url, params=data)
# add stripe info to the seller
stripe_user_id = resp.json()['stripe_user_id']
stripe_access_token = resp.json()['access_token']
seller = Seller.objects.filter(user_id=self.request.user.id).first()
seller.stripe_access_token = stripe_access_token
seller.stripe_user_id = stripe_user_id
seller.save()
url = reverse('home')
response = redirect(url)
return response`
将导入添加到顶部:
`from .models import Seller`
您可能还想保存
refresh_token
,以便请求新的access_token
。
概括地说,在用户连接 Stripe 帐户后,您会获得一个临时授权码,用于请求用户的访问令牌和 id,然后分别用于连接到 Stripe 和代表用户处理支付。
再测试一次。完成后,注销,以超级用户身份重新登录,并验证 Django admin 中的卖家是否已更新:
最后,如果用户已经连接了他们的条带帐户,则隐藏主页模板中的“连接条带帐户”按钮:
`{% if user.is_seller and not user.seller.stripe_user_id %}
<a href="{% url 'authorize' %}" class="button is-info">Connect Stripe Account</a>
{% endif %}`
这样,我们就可以把注意力转向采购方面了。
购买课程
首先,你需要决定如何处理这笔费用:
在本教程中,我们将研究前两种方法。请记住,账户类型和支付方式决定了负债:
账户类型 | 支付方式 | 责任 |
---|---|---|
标准 | 直接的 | 用户 |
标准 | 目的地 | 平台 |
表达 | 直接的 | 用户 |
表达 | 目的地 | 平台 |
表达 | 分开收费和转账 | 平台 |
习俗 | 直接的 | 用户 |
习俗 | 目的地 | 平台 |
习俗 | 分开收费和转账 | 平台 |
这也有例外,所以请务必阅读选择方法指南,了解这三种方法之间差异的更多信息。
TestDriven.io 使用目的地收费,因为所有收费和客户都是平台“拥有”的,而不是关联账户。
直接的
如果您希望由连接的 Stripe 帐户而不是平台帐户处理付款,请使用直接收费。
在 apps/courses/views.py 中更新CourseChargeView
如下:
`class CourseChargeView(View):
def post(self, request, *args, **kwargs):
stripe.api_key = settings.STRIPE_SECRET_KEY
json_data = json.loads(request.body)
course = Course.objects.filter(id=json_data['course_id']).first()
try:
charge = stripe.Charge.create(
amount=json_data['amount'],
currency='usd',
source=json_data['token'],
description=json_data['description'],
stripe_account=course.seller.stripe_user_id,
)
if charge:
return JsonResponse({'status': 'success'}, status=202)
except stripe.error.StripeError as e:
return JsonResponse({'status': 'error'}, status=500)`
试试这个。
您是否注意到该费用仅显示在关联帐户的仪表板上?通过直接收费,客户在技术上是从与关联账户相关的企业购买,而不是从平台购买。关联账户负责支付 Stripe 费用以及任何潜在的退款或退款。如果您需要检查费用,您可以从 API 中检索它。
也可以对一个客户对象收费。想想你想让客户住在哪里——平台账户、关联账户,还是两者兼而有之?
`class CourseChargeView(View):
def post(self, request, *args, **kwargs):
stripe.api_key = settings.STRIPE_SECRET_KEY
json_data = json.loads(request.body)
course = Course.objects.filter(id=json_data['course_id']).first()
try:
customer = stripe.Customer.create(
email=self.request.user.email,
source=json_data['token'],
stripe_account=course.seller.stripe_user_id,
)
charge = stripe.Charge.create(
amount=json_data['amount'],
currency='usd',
customer=customer.id,
description=json_data['description'],
stripe_account=course.seller.stripe_user_id,
)
if charge:
return JsonResponse({'status': 'success'}, status=202)
except stripe.error.StripeError as e:
return JsonResponse({'status': 'error'}, status=500)`
在本例中,在关联帐户上创建客户,然后使用该客户 id 来处理费用。
如果该客户已经存在于关联账户中,该怎么办?
`class CourseChargeView(View):
def post(self, request, *args, **kwargs):
stripe.api_key = settings.STRIPE_SECRET_KEY
json_data = json.loads(request.body)
course = Course.objects.filter(id=json_data['course_id']).first()
try:
customer = get_or_create_customer(
self.request.user.email,
json_data['token'],
course.seller.stripe_access_token,
course.seller.stripe_user_id,
)
charge = stripe.Charge.create(
amount=json_data['amount'],
currency='usd',
customer=customer.id,
description=json_data['description'],
stripe_account=course.seller.stripe_user_id,
)
if charge:
return JsonResponse({'status': 'success'}, status=202)
except stripe.error.StripeError as e:
return JsonResponse({'status': 'error'}, status=500)
# helpers
def get_or_create_customer(email, token, stripe_access_token, stripe_account):
stripe.api_key = stripe_access_token
connected_customers = stripe.Customer.list()
for customer in connected_customers:
if customer.email == email:
print(f'{email} found')
return customer
print(f'{email} created')
return stripe.Customer.create(
email=email,
source=token,
stripe_account=stripe_account,
)`
测试一下,确保只有在客户对象不存在的情况下才创建它。
如果您想在两个帐户之间“共享”客户,该怎么办?在这种情况下,您可能希望将客户信息存储在平台帐户上,以便在不涉及关联帐户时可以直接向该客户收费,然后在涉及关联帐户时使用相同的客户对象来处理收费。如果没有必要,没有必要创建客户两次。在您的上实现此功能。更多信息请参考共享客户指南。
在进入下一个方法之前,让我们快速看一下平台如何在每笔交易中收取便利费:
`class CourseChargeView(View):
def post(self, request, *args, **kwargs):
stripe.api_key = settings.STRIPE_SECRET_KEY
json_data = json.loads(request.body)
course = Course.objects.filter(id=json_data['course_id']).first()
fee_percentage = .01 * int(course.fee)
try:
customer = get_or_create_customer(
self.request.user.email,
json_data['token'],
course.seller.stripe_access_token,
course.seller.stripe_user_id,
)
charge = stripe.Charge.create(
amount=json_data['amount'],
currency='usd',
customer=customer.id,
description=json_data['description'],
application_fee=int(json_data['amount'] * fee_percentage),
stripe_account=course.seller.stripe_user_id,
)
if charge:
return JsonResponse({'status': 'success'}, status=202)
except stripe.error.StripeError as e:
return JsonResponse({'status': 'error'}, status=500)`
现在,在费用处理后,您应该会在平台帐户上看到收取的费用:
目的地
目的地收费当你(平台)想要维持对客户的所有权时效果最好。通过这种方法,平台帐户向客户收费,并负责支付 Stripe 费用以及任何潜在的退款或退款。
`class CourseChargeView(View):
def post(self, request, *args, **kwargs):
stripe.api_key = settings.STRIPE_SECRET_KEY
json_data = json.loads(request.body)
course = Course.objects.filter(id=json_data['course_id']).first()
fee_percentage = .01 * int(course.fee)
try:
customer = get_or_create_customer(
self.request.user.email,
json_data['token'],
)
charge = stripe.Charge.create(
amount=json_data['amount'],
currency='usd',
customer=customer.id,
description=json_data['description'],
destination={
'amount': int(json_data['amount'] - (json_data['amount'] * fee_percentage)),
'account': course.seller.stripe_user_id,
},
)
if charge:
return JsonResponse({'status': 'success'}, status=202)
except stripe.error.StripeError as e:
return JsonResponse({'status': 'error'}, status=500)
# helpers
def get_or_create_customer(email, token):
stripe.api_key = settings.STRIPE_SECRET_KEY
connected_customers = stripe.Customer.list()
for customer in connected_customers:
if customer.email == email:
print(f'{email} found')
return customer
print(f'{email} created')
return stripe.Customer.create(
email=email,
source=token,
)`
测试一下。这应该会在平台上创建一个客户和费用。您还应该看到向关联帐户的转账:
结论
本教程将带您完成设置 Stripe Connect 以代表他人安全管理支付的过程。
您现在应该能够:
- 解释什么是条带连接,以及为什么您可能需要使用它
- 描述条带连接帐户类型之间的相似性和差异
- 将 Stripe Connect 集成到现有的 Django 应用程序中
- 使用类似 Oauth 的流程将 Stripe 帐户链接到 Django 应用程序
- 解释直接费用和目的地费用的区别
寻找一些挑战?
- 添加注册表单和用户帐户页面
- 销售完成后,异步向买方和卖方发送电子邮件
- 尝试自定义帐户,以便为最终用户提供无缝的条带连接集成
- 处理订阅
- 为买方添加销售仪表板页面
你可以在 GitHub 上的django-stripe-connectrepo 中找到最终代码。干杯!
使用 Flask 生成静态站点并将其部署到 Netlify
原文:https://testdriven.io/blog/static-site-flask-and-netlify/
本教程着眼于如何利用 Python 的 JAMstack 和 Flask 。你将学习如何用 Flask 通过freeze-Flask生成一个静态站点,并将其部署到 Netlify 。我们还将看看如何用 pytest 测试静态站点。
本教程假设你有使用 Flask 的经验。如果你有兴趣学习更多关于 Flask 的知识,请查看我的课程,学习如何构建、测试和部署 Flask 应用程序:用 Python 和 Flask 开发 Web 应用程序。
在本教程中创建的网站可以在:https://www.kennedyrecipes.com找到
静态与动态网站
静态网站旨在通过为每个用户显示相同的内容来提供信息。与此同时,动态网站提供不同的内容,并通过支持用户互动来实现功能。
以下是这些差异的总结:
描述 | 静态站点 | 动态网站 |
---|---|---|
向用户显示内容? | ✅ | ✅ |
允许用户交互(表单)? | ❌ | ✅ |
客户端文件(HTML,CSS)? | ✅ | ✅ |
服务器端代码(Python 等。)? | ❌ | ✅ |
无服务器托管(Netlify 等。)? | ✅ | ❌ |
需要 web 服务器资源? | ❌ | ✅ |
“无服务器托管”类别旨在表明静态网站可以使用无服务器解决方案(例如,Netlify、Cloudflare、GitHub Pages 等)轻松部署。).动态网站也可以使用无服务器解决方案(如 AWS Lambda)托管,但这是一个复杂得多的过程。
静态网站为访问网站的每个用户显示相同的固定内容。通常,静态网站是用 HTML、CSS 和 JavaScript 编写的。
另一方面,动态网站可以向每个用户显示不同的内容,并提供用户交互(登录/注销、创建和修改数据库中的项目等)。).动态网站比静态网站复杂得多,因为它们需要服务器端资源和应用程序代码来处理请求。需要维护应用程序代码(崩溃修复、安全更新、语言升级等)。)也是。
为什么要开发静态网站?
如果你正在创建一个旨在提供信息的网站,静态网站是一个很好的选择。静态网站比动态网站更容易创建和维护,只要你了解它们的局限性。
JAMstack
JAMstack 是一个 web 架构,专注于两个关键概念:预渲染内容和解耦服务。
- JAM——JavaScript、API 和标记
- 栈 -技术层
预呈现内容意味着前端内容(HTML、CSS、JavaScript 和其他静态文件)被构建到静态站点中。这个过程的优点是静态内容可以从一个 CDN (内容交付网络)快速提供给网络浏览器。
在本教程中,我们将使用 Netlify 来部署静态站点。多亏了广泛的 CDN,Netlify 能以闪电般的速度为网站提供服务。
分离服务意味着利用提供服务和产品的令人难以置信的 API 集。API 可用于:
更多 API 服务,请查看令人敬畏的静态网站服务。
与传统的 web 应用相比,JAMstack 应用的层数更少:
来源:JAMstack.org
使用 JAMstack(相对于传统方法)的一个关键原因是尽可能“无服务器”,依靠托管解决方案(Netlify)和外部服务(API)。
JAMstack 是建筑的绝佳架构选择:
- 依赖外部服务(API)的客户端应用
- 为用户提供信息的静态站点
当构建侧重于服务器端应用的数据库驱动的应用时,传统的 web 应用是一个很好的方法。
可供选择的事物
内容管理系统(CMS)解决方案
CMS 解决方案用于管理和部署网站。WordPress 是最流行的 CMS 工具,很多产品都是使用 WordPress 开发的。
如今,建立网站有很多复杂的选择,包括:
这些选项中的大部分都允许在不编写任何代码的情况下创建网站。这些选项是快速开发显示内容的网站(如博客)的绝佳选择。
我目前正在使用 WordPress 创建我的个人博客网站:https://www.patricksoftwareblog.com。
Lektor 是一个流行的用 Python 编写的 CMS 解决方案,尽管它也有很多静态站点生成器的特性。
莱克特是由阿明·罗纳彻创造的,他也是烧瓶的创造者!
静态现场发电机
静态站点生成器通过解析用 markdown 语言(通常是 Markdown 或 reStructuredText )创建的内容,创建静态文件(HTML、CSS 和 JavaScript)来发布网站。
还有几个基于 Python 的选项。
Pelican 是用 Python 编写的最流行的静态站点生成器之一。它有一些强大的功能:
- 内容是用 Markdown 或 reStructuredText 编写的
- 用于生成静态内容的 CLI(命令行界面)工具
- 快速开发网页的主题
- 以多种语言出版
带有冷冻瓶的瓶子(我们将在本教程中使用)也可以被认为是一个静态站点生成器。这种方法的优点是能够利用 Flask 的现有开发过程来开发静态站点。此外,在使用 Flask 和 freeze-Flask 时,测试静态站点的能力是一个很大的优势,因为在开发静态站点时测试经常被忽略。
本教程中的方法不是“纯”静态站点生成器,因为内容是在 HTML 文件中创建的。为了使这种方法成为“纯”静态站点生成器,您可以利用 Flask-FlatPages 在 Markdown 中创建内容。
如果你更熟悉 Django,Django-distilt是 Django 应用程序的静态站点生成器。
为什么对静态站点使用 Flask?
如果您已经习惯使用 Python 和 Flask 开发应用程序,那么您可以继续使用相同的工具和工作流来开发静态站点。换句话说,没有必要学习任何新的工具或语言。
使用 Flask,您可以继续使用以下工具和流程:
- 用于生成 HTML 代码的 Jinja 模板(包括模板继承)
- 组织项目的蓝图
- 进行更改时,开发服务器可以热重装(不需要任何复杂的编译步骤)
- 使用 pytest 进行测试
此外,如果您决定在未来将您的网站扩展为一个需要后端数据库的完整 web 应用程序,因为您是从 Flask 开始的,所以您不需要重新编写应用程序。你只需要:
- 移除冷冻烧瓶
- 与数据库接口
- 部署以呈现(或类似的托管解决方案)
工作流程
下图说明了使用 Flask 开发静态站点并将其部署到 Netlify 的典型工作流:
让我们深入了解这个工作流程的细节...
烧瓶项目
虽然这个 Flask 项目将生成静态文件,但它仍然使用 Flask 应用程序的最佳实践:
另外,冻瓶包用于生成静态内容。
本教程中创建的项目的源代码可以在 GitLab 上找到: Flask Recipe App 。
项目结构
项目的文件夹结构是 Flask 项目的典型结构:
`├── project
│ ├── build # Static files are created here by Frozen-Flask!
│ ├── blog # Blueprint for blog posts
│ │ └── templates # Templates specific to the blog blueprint
│ ├── recipes # Blueprint for recipes
│ │ └── templates # Templates specific to the recipes blueprint
│ ├── static
│ │ ├── css # CSS files for styling the pages
│ │ └── img # Images displayed in recipes and blog posts
│ └── templates # Base templates
├── tests
│ └── functional # Test files
└── venv`
要突出显示的关键文件夹是“project/build”文件夹,它将由带有静态文件的 freeze-Flask 包生成。
首先,从这个 GitLab 仓库中下载源代码:
`$ git clone https://gitlab.com/patkennedy79/flask-recipe-app.git`
创建新的虚拟环境:
`$ cd flask-recipe-app
$ python3 -m venv venv`
激活虚拟环境:
`$ source venv/bin/activate`
安装 requirements.txt 中指定的 Python 包:
`(venv)$ pip install -r requirements.txt`
你可以随意把 virtualenv 和 pip 换成诗歌或 Pipenv 。更多信息,请查看现代 Python 环境。
配方路线
在recipes
蓝图中定义了网站上显示的配方路线。
蓝图允许你干净地将你的 Flask 项目的源代码组织成不同的组件。每个蓝图都应该封装应用程序中的重要功能。
例如,使用以下变量和视图函数在project/recipes/routes . py中定义早餐食谱:
`from . import recipes_blueprint
from flask import render_template, abort
breakfast_recipes_names = ['pancakes', 'acai_bowl', 'honey_bran_muffins', 'breakfast_scramble',
'pumpkin_donuts', 'waffles', 'omelette']
@recipes_blueprint.route('/breakfast/')
def breakfast_recipes():
return render_template('recipes/breakfast.html')
@recipes_blueprint.route('/breakfast/<recipe_name>/')
def breakfast_recipe(recipe_name):
if recipe_name not in breakfast_recipes_names:
abort(404)
return render_template(f'recipes/{recipe_name}.html')`
breakfast_recipes()
视图功能呈现显示所有早餐食谱的模板。
breakfast_recipe(recipe_name)
视图功能呈现指定的早餐食谱。如果指定了无效的配方标题,则返回 404(未找到)错误。
这一组相同的查看功能用于每种配方类型:
- 早餐
- 主餐
- 正菜外的附加菜
- 甜点
- 八面玲珑的男人/果汁
- 烘焙食品
Jinja 模板
Flask 附带了开箱即用的 Jinja 模板引擎,我们将使用它来生成我们的 HTML 文件。
模板文件包含变量和/或表达式,当模板被渲染时,这些变量和/或表达式被替换为值:
模板继承允许模板文件继承其他模板。您可以创建一个基础模板来定义网站的布局。由于子模板将使用这种布局,它们可以只关注内容。
基础模板在project/templates/base . html中定义:
`<!DOCTYPE html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Flask Recipe App</title>
<!-- Local CSS file for styling the application-->
<link rel="stylesheet" href="{{ url_for('static', filename='css/base_style.css') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/x-icon">
<!-- Additional Styling -->
{% block styling %}
{% endblock %}
</head>
<body>
<header>
<h1>Kennedy Family Recipes</h1>
<nav>
<ul>
<li class="nav__item"><a href="{{ url_for('recipes.recipes') }}" class="nav__link">Recipes</a></li>
<li class="nav__item"><a href="{{ url_for('blog.blog') }}" class="nav__link">Blog</a></li>
<li class="nav__item"><a href="{{ url_for('blog.about') }}" class="nav__link">About</a></li>
</ul>
</nav>
</header>
<main class="content">
<!-- child template -->
{% block content %}
{% endblock %}
</main>
<footer>
<p>Created by <a href="https://www.patricksoftwareblog.com/">Patrick Kennedy</a> (2023)</p>
</footer>
</body>
</html>`
基础模板定义了导航栏(<header>
标签)和页脚(<footer>
标签)。要显示的内容在<main>
标签中指定,但是该内容应该由子模板填充。
例如,用于显示早餐食谱列表的模板(在project/recipes/templates/recipes/breakfast . html中定义)在基础模板上展开,以显示所有的早餐食谱:
`{% extends "base.html" %}
{% block content %}
<div class="recipe-container">
<div class="card">
<a href="{{ url_for('recipes.breakfast_recipe', recipe_name='pancakes') }}">
<img
src="{{ url_for('static', filename='img/pancakes.jpg') }}"
alt="Pancakes"
class="card__image" />
<div class="card__body">
<h2>Pancakes</h2>
<p class="recipe-badge dairy-free-badge">Dairy-Free</p>
<p class="recipe-badge soy-free-badge">Soy-Free</p>
</div>
</a>
</div>
<div class="card">
<a href="{{ url_for('recipes.breakfast_recipe', recipe_name='honey_bran_muffins') }}">
<img
src="{{ url_for('static', filename='img/honey_bran_muffins.jpg') }}"
alt="Honey Bran Muffins"
class="card__image" />
<div class="card__body">
<h2>Honey Bran Muffins</h2>
<p class="recipe-badge dairy-free-badge">Dairy-Free</p>
<p class="recipe-badge soy-free-badge">Soy-Free</p>
</div>
</a>
</div>
...
</div>
{% endblock %}`
测试
pytest 是 Python 的一个测试框架,用于编写、组织和运行测试用例。在建立了基本的测试结构之后,pytest 使得编写测试变得容易,并且为运行测试提供了很大的灵活性。
测试文件在测试/功能/ 目录中指定。例如,早餐食谱的测试在tests/functional/test _ recipes . py中指定:
`"""
This file (test_recipes.py) contains the functional tests for the `recipes` blueprint.
"""
from project.recipes.routes import breakfast_recipes_names
def test_get_breakfast_recipes(test_client):
"""
GIVEN a Flask application configured for testing
WHEN the '/breakfast/' page is requested (GET)
THEN check the response is valid
"""
recipes = [b'Pancakes', b'Honey Bran Muffins', b'Acai Bowl',
b'Breakfast Scramble', b'Pumpkin Donuts', b'Waffles',
b'Omelette']
response = test_client.get('/breakfast/')
assert response.status_code == 200
for recipe in recipes:
assert recipe in response.data
def test_get_individual_breakfast_recipes(test_client):
"""
GIVEN a Flask application configured for testing
WHEN the '/breakfast/<recipe_name>' page is requested (GET)
THEN check the response is valid
"""
for recipe_name in breakfast_recipes_names:
response = test_client.get(f'/breakfast/{recipe_name}/')
assert response.status_code == 200
assert str.encode(recipe_name) in response.data`
这些是高层次的检查,以确保预期的页面正确呈现。
这些测试功能中的每一个都使用在测试/conftest.py 中定义的test_client
夹具:
`import pytest
from project import create_app
@pytest.fixture(scope='module')
def test_client():
flask_app = create_app()
# Create a test client using the Flask application configured for testing
with flask_app.test_client() as testing_client:
yield testing_client # this is where the testing happens!`
测试应该从顶级目录运行:
他们应该通过:
`================================ test session starts =================================
platform darwin -- Python 3.11.0, pytest-7.2.1, pluggy-1.0.0
plugins: cov-4.0.0
collected 18 items
tests/functional/test_blog.py .... [22%]
tests/functional/test_recipes.py .............. [100%]
================================ 18 passed in 0.33s ==================================`
冷冻烧瓶
到目前为止,Flask 应用程序看起来像一个典型的 web 应用程序。这就是冷冻瓶发挥作用的地方。我们可以自动生成静态文件和 URL。
回想一下到目前为止的项目。我们为什么需要创建 URL?
- 在项目/静态中找到静态文件
- 在项目/recipes/routes.py 中找到的路线
freeze-Flask 将自动生成静态文件的所有 URL。例如:
static/css/base_style.css
static/img/acai_bowl.jpg
记下项目/配方/路线. py :
`@recipes_blueprint.route('/')
def recipes():
return render_template('recipes/recipes.html')
@recipes_blueprint.route('/breakfast/')
def breakfast_recipes():
return render_template('recipes/breakfast.html')
@recipes_blueprint.route('/breakfast/<recipe_name>/')
def breakfast_recipe(recipe_name):
if recipe_name not in breakfast_recipe_names:
abort(404)
return render_template(f'recipes/{recipe_name}.html')`
freezed-Flask 将自动为 GET routes 生成 URL,URL 中没有可变部分。在我们的例子中,它将为/
和/breakfast/
生成 URL。
'/breakfast/<recipe_name>/
怎么样?来自url_for()
的任何链接也将被发现,这应该涵盖一切。
例如,breakfast.html模板包含每种早餐食谱的
url_for()
调用。
发展
在开发过程中,我们希望能够在本地计算机上测试路线,就像任何 Flask 应用程序一样。
app.py 文件用于运行 Flask 开发服务器:
`from flask_frozen import Freezer
from project import create_app
# Call the application factory function to construct a Flask application
# instance using the development configuration
app = create_app()
# Create an instance of Freezer for generating the static files from
# the Flask application routes ('/', '/breakfast', etc.)
freezer = Freezer(app)
if __name__ == '__main__':
# Run the development server that generates the static files
# using Frozen-Flask
freezer.run(debug=True)`
该文件首先调用应用程序工厂函数,创建 Flask 应用程序。接下来,创建一个Freezer
(来自 freeze-Flask)实例。最后,运行来自 freeze-Flask 的开发服务器:
启动开发服务器就像任何其他 Flask 应用程序一样:
`(venv)$ flask --app app --debug run`
现在您可以导航到 http://127.0.0.1:5000/ :
构建脚本
一旦您准备好部署静态文件,您需要构建文件来生成包含应用程序内容的静态文件。顶层文件夹中的 build.py 脚本运行 Freezer-Flask 生成静态文件:
`from flask_frozen import Freezer
from project import create_app
# Call the application factory function to construct a Flask application
# instance using the development configuration
app = create_app()
# Create an instance of Freezer for generating the static files from
# the Flask application routes ('/', '/breakfast', etc.)
freezer = Freezer(app)
if __name__ == '__main__':
# Generate the static files using Frozen-Flask
freezer.freeze()`
运行脚本:
该脚本基于 Flask 应用程序中的路径生成所有静态文件,并将它们写入“项目/构建”文件夹:
`(venv)$ tree -L 3 project/build
project/build
├── breakfast
│ ├── acai_bowl
│ │ └── index.html
│ ├── breakfast_scramble
│ │ └── index.html
│ ├── index.html
│ └── waffles
│ └── index.html
├── index.html
└── static
├── css
│ ├── base_style.css
│ └── recipe_style.css
└── img`
这个文件夹就是我们要部署到 Netlify 的!
部署到网络
什么是 Netlify?
Netlify 是一项简化托管前端 web 应用的服务。
Netlify 提供前端 web 应用程序的免费托管,他们还提供为您的应用程序购买自定义域名的能力。我特别喜欢他们的服务,因为他们为 HTTPS 提供所有的托管解决方案。
在我们进入这些步骤之前,您需要确保在 Netlify 上创建一个帐户。前往https://www.netlify.com,点击“注册”创建一个新账户(这是免费的,不需要信用卡)。
Netlify 的一些替代方案有: Cloudflare , GitHub Pages ,isGitLab Pages。
安装ˌ使成形
登录 Netlify 后,转到您的帐户页面,点击“添加新网站”,然后点击“导入现有项目”:
现在,您可以选择 git 托管解决方案(GitLab、GitHub、BitBucket、Azure DevOps)来存储您的 git 存储库:
如果您以前没有将 Netlify 连接到您的 git 托管服务,将有一个额外的步骤允许 Netlify 访问您的 git 存储库。接下来,选择您想要托管的 git 存储库。
现在,您可以选择谁是项目的所有者,以及您希望从哪个分支部署构建(我选择“main”分支,因为这是我为这个项目构建的稳定分支):
如果您对将 git 存储库中的默认分支从
master
更改为main
的步骤感兴趣,请参考这篇非常有用的博文:将您的 Git 默认分支从 master 重命名为 main(带有 GitLab 截图)。
继续向下滚动,您现在可以选择运行哪个命令来构建您的应用程序,以及应该从哪里部署代码:
由于部署了一组静态文件(HTML、CSS、JavaScript ),因此只需将“发布目录”指定为静态文件组的路径: /project/build/
使用此配置集,单击“部署站点”按钮。
Netlify 需要一些时间(< 1 分钟)来部署站点:
您应该会看到该站点的预览,并确认该站点已部署。
点击链接(如https://enchanting-arithmetic-3ca519.netlify.app/)查看网站!
现在,您可以从任何连接到互联网的设备上查看该 web 应用程序!
工作流程
与许多流程一样,配置步骤有点复杂,但现在 Netlify 会注意到您的存储库(在 GitLab、GitHub 或 BitBucket 上)的“主”分支上的任何新提交,并使用新提交重新部署您的网站。请记住:Netlify 将发布“项目/构建”文件夹中的文件,因此请确保在提交更改之前始终构建静态站点:
回顾一下,下图说明了使用 Flask 开发静态站点并将其部署到 Netlify 的典型工作流:
Netlify 的其他功能
Netlify 还提供了几个工具,用于向应用程序添加类似服务器的功能,而实际上并不需要服务器端的应用程序。例如:
结论
本教程展示了如何创建一个 Flask 应用程序,然后使用 freeze-Flask 为所有指定的路由生成静态文件。然后静态文件被发布到 Netlify 以部署静态站点。
如果您熟悉 Flask 应用程序的开发,那么这个过程是开发可以轻松部署到 web 上的静态站点的好方法。
如果你有兴趣学习更多关于 Flask 的知识,请查看我的课程,学习如何构建、测试和部署 Flask 应用程序:用 Python 和 Flask 开发 Web 应用程序。
在亚马逊 S3 上存储 Django 静态和媒体文件
原文:https://testdriven.io/blog/storing-django-static-and-media-files-on-amazon-s3/
亚马逊的简单存储系统 (S3)提供了一种简单、经济的方式来存储静态文件。本教程展示了如何配置 Django,通过一个亚马逊 S3 桶来加载和提供静态的和用户上传的媒体文件,公共的和私有的。
主要依赖关系:
- django 4 . 1 . 5 版
- 文档 v20.10.22
- python 3 . 11 . 1 版
更喜欢使用数字海洋空间?查看在数字海洋空间上存储 Django 静态和媒体文件的。
S3 水桶
开始之前,你需要一个 AWS 账户。如果你是 AWS 新手,亚马逊提供了一个带有 5GB S3 存储的免费层。
要创建一个 S3 存储桶,导航到 S3 页面并点击“创建存储桶”:
给 bucket 起一个唯一的、符合 DNS 的名字,并选择一个地区:
在“对象所有权”下,选择“启用 ACL”。
关闭“阻止所有公共访问”:
创建存储桶。现在,您应该可以在 S3 主页上看到您的存储桶了:
IAM 访问
尽管您可以使用 AWS root 用户,但出于安全考虑,最好创建一个 IAM 用户,该用户只能访问 S3 或特定的 S3 存储桶。此外,通过建立一个组,分配(和删除)对 bucket 的访问变得更加容易。因此,我们将首先建立一个具有有限权限的组,然后创建一个用户并将该用户分配到该组。
IAM 集团
在 AWS 控制台中,导航至主 IAM 页面,点击侧边栏上的“用户组”。然后,单击“创建组”按钮。为该组提供一个名称,然后搜索并选择内置策略“AmazonS3FullAccess”:
点击“创建群组”完成群组设置:
如果您想对我们刚刚创建的特定存储桶进一步限制访问,请创建一个具有以下权限的新策略:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "s3:*", "Resource": [ "arn:aws:s3:::your-bucket-name", "arn:aws:s3:::your-bucket-name/*" ] } ] }
一定要用实际名称替换
your-bucket-name
。然后,从组中分离“AmazonS3FullAccess”策略,并附加新策略。
IAM 用户
回到主 IAM 页面,点击“用户”,然后点击“添加用户”。定义用户名,然后单击“下一步”按钮。
在“权限”步骤中,选择我们刚刚创建的组:
单击“创建用户”创建新用户。
现在,单击用户名查看用户详细信息。单击“安全凭证”选项卡,然后单击“创建访问密钥”。选择“本地代码”并单击下一步按钮。
之后,点击“创建访问密钥”按钮并记下密钥。
Django 项目
克隆下 django-docker-s3 repo,然后检查基地分支:
`$ git clone https://github.com/testdrivenio/django-docker-s3 --branch base --single-branch
$ cd django-docker-s3`
从项目根目录,创建映像并启动 Docker 容器:
`$ docker-compose up -d --build`
构建完成后,收集静态文件:
`$ docker-compose exec web python manage.py collectstatic`
然后,导航到 http://localhost:1337 :
您应该能够上传一张图片,然后在http://localhost:1337/media files/IMAGE _ FILE _ NAME查看该图片。
公共与私有的单选按钮不起作用。我们将在本教程的后面添加这个功能。暂时忽略它们。
在继续之前,快速浏览一下项目结构:
`├── .gitignore
├── LICENSE
├── README.md
├── app
│ ├── Dockerfile
│ ├── hello_django
│ │ ├── __init__.py
│ │ ├── asgi.py
│ │ ├── settings.py
│ │ ├── urls.py
│ │ └── wsgi.py
│ ├── manage.py
│ ├── mediafiles
│ ├── requirements.txt
│ ├── static
│ │ └── bulma.min.css
│ ├── staticfiles
│ └── upload
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── migrations
│ │ └── __init__.py
│ ├── models.py
│ ├── templates
│ │ └── upload.html
│ ├── tests.py
│ └── views.py
├── docker-compose.yml
└── nginx
├── Dockerfile
└── nginx.conf`
想学习如何构建这个项目吗?查看关于 Django 与 Postgres、Gunicorn 和 Nginx 的文章。
Django 仓库
接下来,安装django-stores,使用 S3 作为主要的 Django 存储后端,以及 boto3 ,与 AWS API 交互。
更新需求文件:
`boto3==1.26.59
Django==4.1.5
django-storages==1.13.2
gunicorn==20.1.0`
将storages
添加到设置. py 中的INSTALLED_APPS
中:
`INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'upload',
'storages',
]`
更新图像并旋转新容器:
`$ docker-compose up -d --build`
静态文件
接下来,我们需要更新对 settings.py 中静态文件的处理:
`STATIC_URL = '/staticfiles/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)
MEDIA_URL = '/mediafiles/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles')`
用以下内容替换这些设置:
`USE_S3 = os.getenv('USE_S3') == 'TRUE'
if USE_S3:
# aws settings
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
AWS_DEFAULT_ACL = 'public-read'
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'}
# s3 static settings
AWS_LOCATION = 'static'
STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{AWS_LOCATION}/'
STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
else:
STATIC_URL = '/staticfiles/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)
MEDIA_URL = '/mediafiles/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles')`
注意USE_S3
和STATICFILES_STORAGE
:
USE_S3
环境变量用于打开(值为TRUE
)和关闭(值为FALSE
)S3 存储。因此,您可以配置两个 Docker 合成文件:一个用于关闭 S3 的开发,另一个用于打开 S3 的生产。STATICFILES_STORAGE
设置配置 Django 在运行collectstatic
命令时自动将静态文件添加到 S3 桶中。
查看官方 django-storages 文档,了解关于上述设置和配置的更多信息。
向 docker-compose.yml 文件中的web
服务添加适当的环境变量:
`web: build: ./app command: bash -c 'while !</dev/tcp/db/5432; do sleep 1; done; gunicorn hello_django.wsgi:application --bind 0.0.0.0:8000' volumes: - ./app/:/usr/src/app/ - static_volume:/usr/src/app/staticfiles - media_volume:/usr/src/app/mediafiles expose: - 8000 environment: - SECRET_KEY=please_change_me - SQL_ENGINE=django.db.backends.postgresql - SQL_DATABASE=postgres - SQL_USER=postgres - SQL_PASSWORD=postgres - SQL_HOST=db - SQL_PORT=5432 - DATABASE=postgres - USE_S3=TRUE - AWS_ACCESS_KEY_ID=UPDATE_ME - AWS_SECRET_ACCESS_KEY=UPDATE_ME - AWS_STORAGE_BUCKET_NAME=UPDATE_ME depends_on: - db`
不要忘记用您刚刚创建的用户密钥和
AWS_STORAGE_BUCKET_NAME
一起更新AWS_ACCESS_KEY_ID
和AWS_SECRET_ACCESS_KEY
。
要测试、重新构建和运行容器:
`$ docker-compose down -v
$ docker-compose up -d --build`
收集静态文件:
`$ docker-compose exec web python manage.py collectstatic`
由于文件正在被上传到 S3 存储桶,所以这将花费比以前更长的时间。
http://localhost:1337 应该仍能正确渲染:
查看页面源代码以确保 CSS 样式表是从 S3 存储桶中提取的:
验证可以在 AWS 控制台上的 S3 存储桶的“static”子文件夹中看到静态文件:
媒体上传仍然会触及本地文件系统,因为我们只为静态文件配置了 S3。我们将很快处理媒体上传。
最后,将USE_S3
的值更新为FALSE
并重新构建映像,以确保 Django 使用本地文件系统来存储静态文件。完成后,将USE_S3
变回TRUE
。
为了防止用户覆盖现有的静态文件,媒体文件上传应该放在桶中不同的子文件夹中。我们将通过为每种类型的存储创建自定义存储类来解决这个问题。
将名为 storage_backends.py 的新文件添加到“app/hello_django”文件夹中:
`from django.conf import settings
from storages.backends.s3boto3 import S3Boto3Storage
class StaticStorage(S3Boto3Storage):
location = 'static'
default_acl = 'public-read'
class PublicMediaStorage(S3Boto3Storage):
location = 'media'
default_acl = 'public-read'
file_overwrite = False`
对 settings.py 进行以下更改:
`USE_S3 = os.getenv('USE_S3') == 'TRUE'
if USE_S3:
# aws settings
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
AWS_DEFAULT_ACL = None
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'}
# s3 static settings
STATIC_LOCATION = 'static'
STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/'
STATICFILES_STORAGE = 'hello_django.storage_backends.StaticStorage'
# s3 public media settings
PUBLIC_MEDIA_LOCATION = 'media'
MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_MEDIA_LOCATION}/'
DEFAULT_FILE_STORAGE = 'hello_django.storage_backends.PublicMediaStorage'
else:
STATIC_URL = '/staticfiles/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
MEDIA_URL = '/mediafiles/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles')
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)`
随着DEFAULT_FILE_STORAGE
设置现在被设置,所有的文件域将它们的内容上传到 S3 桶。继续之前,请检查其余设置。
接下来,让我们对upload
应用程序做一些修改。
app/upload/models.py :
`from django.db import models
class Upload(models.Model):
uploaded_at = models.DateTimeField(auto_now_add=True)
file = models.FileField()`
app/upload/views.py :
`from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.shortcuts import render
from .models import Upload
def image_upload(request):
if request.method == 'POST':
image_file = request.FILES['image_file']
image_type = request.POST['image_type']
if settings.USE_S3:
upload = Upload(file=image_file)
upload.save()
image_url = upload.file.url
else:
fs = FileSystemStorage()
filename = fs.save(image_file.name, image_file)
image_url = fs.url(filename)
return render(request, 'upload.html', {
'image_url': image_url
})
return render(request, 'upload.html')`
创建新的迁移文件,然后构建新的映像:
`$ docker-compose exec web python manage.py makemigrations
$ docker-compose down -v
$ docker-compose up -d --build
$ docker-compose exec web python manage.py migrate`
测试一下!在 http://localhost:1337 上传一张图片。图像应该上传到 S3(媒体子文件夹)和image_url
应该包括 S3 的网址:
向 storage_backends.py 添加一个新类:
`class PrivateMediaStorage(S3Boto3Storage):
location = 'private'
default_acl = 'private'
file_overwrite = False
custom_domain = False`
添加适当的设置:
`USE_S3 = os.getenv('USE_S3') == 'TRUE'
if USE_S3:
# aws settings
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
AWS_DEFAULT_ACL = None
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'}
# s3 static settings
STATIC_LOCATION = 'static'
STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/'
STATICFILES_STORAGE = 'hello_django.storage_backends.StaticStorage'
# s3 public media settings
PUBLIC_MEDIA_LOCATION = 'media'
MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_MEDIA_LOCATION}/'
DEFAULT_FILE_STORAGE = 'hello_django.storage_backends.PublicMediaStorage'
# s3 private media settings
PRIVATE_MEDIA_LOCATION = 'private'
PRIVATE_FILE_STORAGE = 'hello_django.storage_backends.PrivateMediaStorage'
else:
STATIC_URL = '/staticfiles/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
MEDIA_URL = '/mediafiles/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles')
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)`
在 app/upload/models.py 中创建新模型:
`from django.db import models
from hello_django.storage_backends import PublicMediaStorage, PrivateMediaStorage
class Upload(models.Model):
uploaded_at = models.DateTimeField(auto_now_add=True)
file = models.FileField(storage=PublicMediaStorage())
class UploadPrivate(models.Model):
uploaded_at = models.DateTimeField(auto_now_add=True)
file = models.FileField(storage=PrivateMediaStorage())`
然后,更新视图:
`from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.shortcuts import render
from .models import Upload, UploadPrivate
def image_upload(request):
if request.method == 'POST':
image_file = request.FILES['image_file']
image_type = request.POST['image_type']
if settings.USE_S3:
if image_type == 'private':
upload = UploadPrivate(file=image_file)
else:
upload = Upload(file=image_file)
upload.save()
image_url = upload.file.url
else:
fs = FileSystemStorage()
filename = fs.save(image_file.name, image_file)
image_url = fs.url(filename)
return render(request, 'upload.html', {
'image_url': image_url
})
return render(request, 'upload.html')`
同样,创建迁移文件,重新构建映像,并启动新容器:
`$ docker-compose exec web python manage.py makemigrations
$ docker-compose down -v
$ docker-compose up -d --build
$ docker-compose exec web python manage.py migrate`
为了测试,在 http://localhost:1337 上传一个私有镜像。像公共图像一样,图像应该上传到 S3(到私有子文件夹),并且image_url
应该包括 S3 URL 以及以下查询字符串参数:
- AWSAccessKeyId
- 签名
- 期满
本质上,我们创建了一个临时的、已签名的 URL,用户可以在特定的时间段内访问它。没有参数,您将无法直接访问它。
结论
本教程向您展示了如何在 Amazon S3 上创建一个 bucket,配置一个 IAM 用户和组,并设置 Django 来上传和服务静态文件和来自 S3 的媒体上传。
通过使用 S3,您可以:
- 增加静态文件和媒体文件的可用空间
- 减轻您自己的服务器的压力,因为它不再需要提供文件
- 可以限制对特定文件的访问
- 可以利用 CloudFront CDN
如果我们遗漏了什么,或者您有任何其他提示和技巧,请告诉我们。你可以在 django-docker-s3 repo 中找到最终代码。
任务队列教程
描述
任务队列在用户请求之外异步管理后台工作。对于 web 开发,它们用于处理典型的请求/响应周期之外的任务。它们在微服务架构中非常流行,用于微服务间的通信。Celery 和 RQ 是两种最流行的基于 Python 的任务队列。
TestDriven.io 上的教程和文章关注于设置和配置任务队列,以便与 Docker 和各种 Python web 框架(如 Django、Flask 和 FastAPI)一起工作。
本文着眼于如何在 Django 应用程序中配置 Celery 来处理长时间运行的任务。
本教程展示了如何将 Celery 集成到基于 Python 的 Falcon web 框架中。
查看如何配置 Celery 来处理 Flask 应用程序中的长时间运行的任务。
查看如何配置 Redis 队列(RQ)来处理 Flask 应用程序中的长时间运行的任务。
- 发帖者 迈克尔·尹
- 最后更新于2021 年 12 月 13 日
让 Celery 很好地与 Django 数据库事务一起工作。
- 发帖者 迈克尔·尹
- 最后更新于2021 年 12 月 11 日
自动重试失败的芹菜任务。
如何使用 Python 多重处理库和 Redis 实现几个异步任务队列?
向 Flask、Redis Queue 和 Amazon SES 的新注册用户发送确认电子邮件。
这篇文章着眼于如何管理 Django、Celery 和 Docker 的周期性任务。
这篇文章着眼于如何配置 Celery 来处理 FastAPI 应用程序中的长时间运行的任务。
用测试驱动开发自信地改进代码
像许多开发人员一样,当我第一次接触到测试驱动开发 (TDD)时,我一点也不理解它。我不知道(也没有耐心)如何先开始写测试。因此,在添加测试之前,我没有花太多精力,而是按照正常的流程编写代码。这种情况持续了好几年。
早在 2017 年,我就共同创立了 typless ,目前我在那里领导工程工作。我们必须在开始时快速行动,因此我们积累了相当多的技术债务。当时,这个平台本身是由一个大型的整体 Django 应用程序支持的。调试非常困难。代码很难读懂,更难修改。我们会消灭一只虫子,然后另外三只虫子会取代它。说到这里,是时候给 TDD 一次机会了。我所读到的一切都表明它会有所帮助——事实也的确如此。我终于看到了 TDD 的一个核心副作用:它使得代码修改变得更加容易。
软件是有生命的东西
对任何一个软件来说,最重要的质量因素之一就是它有多容易改变。
“好的设计意味着当我做出改变时,就好像整个程序都是在预料之中的。我可以用几个选择函数调用完美地解决一个任务,不会在代码平静的表面留下丝毫波纹。”来源
软件随着业务需求的变化而变化。不管是什么推动了这种变化,昨天行之有效的解决方案今天可能行不通。
更改干净的、模块化的代码要容易得多,由测试覆盖,这正是 TDD 倾向于产生的代码类型。
让我们看一个例子。
要求
假设您有一个客户,希望您开发一个基本的电话簿,用于添加和显示(按字母顺序)电话号码。
您是否应该创建一个数字列表以及一些用于追加、排序和打印的辅助函数?还是应该创建一个类?当然,在这一点上,这可能并不重要。您可以开始编写代码来满足当前的需求。感觉这是最自然的事,对吧?但是,如果这些需求发生了变化,您必须包括搜索或删除,该怎么办呢?如果你不决定一个聪明的策略,代码很快就会变得混乱。
所以退一步,先写一些测试。
首先编写测试
首先,创建(并激活)一个虚拟环境并安装 pytest :
`(venv)$ pip install pytest`
创建一个名为 test_phone_book.py 的新文件来保存您的测试。
用两个方法开始一个类似乎是合理的,add
和all
,对吗?
- 给定一个具有
records
属性的PhoneBook
类 - 当调用
all
方法时 - 那么所有的数字都应该以升序返回
测试应该是这样的:
`class TestPhoneBook:
def test_all(self):
phone_book = PhoneBook(
records=[
('John Doe', '03 234 567 890'),
('Marry Doe', '01 234 567 890'),
('Donald Doe', '02 234 567 890'),
]
)
previous = ''
for record in phone_book.all():
assert record[0] > previous
previous = record[0]`
这里,我们检查前一个元素在字母顺序上总是小于当前元素。
运行它:
测试当然会失败。
为了实现,添加一个名为 phone_book.py 的新文件:
`class PhoneBook:
def __init__(self, records=None):
self.records = records or []
def all(self):
return sorted(self.records)`
将其导入到测试文件中:
`from phone_book import PhoneBook
class TestPhoneBook:
def test_all(self):
phone_book = PhoneBook(
records=[
('John Doe', '03 234 567 890'),
('Marry Doe', '01 234 567 890'),
('Donald Doe', '02 234 567 890'),
]
)
previous = ''
for record in phone_book.all():
assert record[0] > previous
previous = record[0]`
再次运行:
测试现在通过了。你已经满足了第一个要求。
现在为一个add
方法编写一个测试来检查一个新的数字是否在records
中。
- 用一个
add
方法给定一个PhoneBook
- 当添加一个数字并调用
all
方法时 - 那么新号码是返回号码的一部分
`from phone_book import PhoneBook
class TestPhoneBook:
def test_all(self):
phone_book = PhoneBook(
records=[
('John Doe', '03 234 567 890'),
('Marry Doe', '01 234 567 890'),
('Donald Doe', '02 234 567 890'),
]
)
previous = ''
for record in phone_book.all():
assert record[0] > previous
previous = record[0]
def test_add(self):
record = ('John Doe', '01 234 567 890')
phone_book = PhoneBook(
records=[
('Marry Doe', '01 234 567 890'),
('Donald Doe', '02 234 567 890'),
]
)
phone_book.add(record)
assert record in phone_book.all()`
测试应该会失败,因为add
方法还没有实现。
`class PhoneBook:
def __init__(self, records=None):
self.records = records or []
def all(self):
return sorted(self.records)
def add(self, record):
self.records.append(record)`
PhoneBook
类现在满足了上述所有要求。数字可以相加,全部可以按字母顺序排序返回。顾客很高兴。打包并交付代码。
新要求
让我们回顾一下第一个实现。
尽管我们使用测试来更好地定义应该做什么,但是没有测试我们也可以轻松地编写代码。事实上,这些测试似乎减缓了这一过程。
几个星期过去了,你还没有客户的消息。他们一定很喜欢添加和查看电话号码。干得好。给自己一个鼓励,给客户发一个关于未付发票的温馨提示。在你点击发送后不到 30 秒,你收到了一封令人沮丧的电子邮件回复,指出检索数字非常慢。
这是怎么回事?嗯,每次调用all
方法时,你都在对记录进行排序,这将随着时间的推移而变慢。因此,让我们更改代码,在列表初始化和添加新数字时进行排序。
由于我们关注的是测试接口而不是底层实现,我们可以在不破坏测试的情况下修改代码。
`class PhoneBook:
def __init__(self, records=None):
self.records = sorted(records or [])
def add(self, record):
self.records.append(record)
self.records = sorted(self.records)
def all(self):
return self.records`
测试应该还能通过。
这很好,但我们实际上可以加快速度,因为数字已经排序了。
`class PhoneBook:
def __init__(self, records=None):
self.records = sorted(records or [], key=lambda rec: rec[0])
def add(self, record):
index = len(self.records)
for i in range(len(self.records)):
if record[0] < self.records[i][0]:
index = i
break
self.records.insert(index, record)
def all(self):
return self.records`
这里,我们按顺序插入新的数字,并取消排序。
尽管我们已经改变了实现来满足新的需求,但是我们仍然满足最初的需求。我们怎么知道?进行测试。
我们能做得更好吗?
我们已经满足了所有的要求。太好了。我们的客户支付发票。一切都好。时光流逝。你忘了这个项目。然后,你会突然在收件箱里看到他们发来的一封电子邮件,抱怨当添加新号码时,应用程序现在很慢。
您打开文本编辑器并开始调查。忘记了项目,您从测试开始,然后钻研代码。查看add
方法,您会发现您必须在插入之前找到插入数字的确切位置,以保持顺序。这两种方法——插入和搜索插入索引——的时间复杂度都是 O(n)。
那么,如何提高那里的性能呢?
求助谷歌,栈溢出。运用你的信息检索技能。一个小时左右,你发现插入一棵二叉树的时间复杂度是 O(log n)。那更好。除此之外,元素可以通过有序遍历以排序的顺序返回。因此,请继续将您的实现改为使用二叉树而不是列表。
二叉树
不熟悉二叉树?查看 Python 中的二叉树:简介和遍历算法视频以及优秀的二叉树库,
首先,定义一个节点:
`class Node:
def __init__(self, data):
self.left = None
self.right = None
self.data = data`
其次,添加一个插入方法:
`class Node:
def __init__(self, data):
self.left = None
self.right = None
self.data = data
def insert(self, data):
# Compare the new value with the parent node
if self.data:
if data[0] < self.data[0]:
if self.left is None:
self.left = Node(data)
else:
self.left.insert(data)
elif data[0] > self.data[0]:
if self.right is None:
self.right = Node(data)
else:
self.right.insert(data)
else:
self.data = data`
这里,我们检查当前节点是否有数据集。
如果不是,则设置数据。
如果设置了数据,它会检查第一个元素是大于还是小于我们需要插入的数据。在此基础上,它添加左或右节点。
最后,添加有序遍历方法:
`class Node:
def __init__(self, data):
self.left = None
self.right = None
self.data = data
def insert(self, data):
# Compare the new value with the parent node
if self.data:
if data[0] < self.data[0]:
if self.left is None:
self.left = Node(data)
else:
self.left.insert(data)
elif data[0] > self.data[0]:
if self.right is None:
self.right = Node(data)
else:
self.right.insert(data)
else:
self.data = data
def inorder_traversal(self, root):
res = []
if root:
res = self.inorder_traversal(root.left)
res.append(root.data)
res = res + self.inorder_traversal(root.right)
return res`
有了它,我们可以将它实现到我们的PhoneBook
:
`class Node:
def __init__(self, data):
self.left = None
self.right = None
self.data = data
def insert(self, data):
# Compare the new value with the parent node
if self.data:
if data[0] < self.data[0]:
if self.left is None:
self.left = Node(data)
else:
self.left.insert(data)
elif data[0] > self.data[0]:
if self.right is None:
self.right = Node(data)
else:
self.right.insert(data)
else:
self.data = data
def inorder_traversal(self, root):
res = []
if root:
res = self.inorder_traversal(root.left)
res.append(root.data)
res = res + self.inorder_traversal(root.right)
return res
class PhoneBook:
def __init__(self, records=None):
records = records or []
if len(records) == 1:
self.records = Node(records[0])
elif len(records) > 1:
self.records = Node(records[0])
for elm in records[1:]:
self.records.insert(elm)
else:
self.records = Node(None)
def add(self, record):
self.records.insert(record)
def all(self):
return self.records.inorder_traversal(self.records)`
进行测试。他们应该通过。
结论
首先编写测试有助于很好地定义问题,这有助于编写更好的解决方案。
您可以使用测试来帮助澄清问题以及令人困惑的特性范围。
测试然后检查您的解决方案是否解决了问题。
大多数客户不会在乎你如何解决问题,只要它有效;因此,我们将测试的重点放在了接口上,而不是实现上。当我们对代码进行修改时,我们不需要修改我们的测试,因为代码解决的问题没有改变。
随着实现复杂性的增加,可能也有必要在那个级别添加单元测试。我建议将您的时间和注意力集中在实现级别的集成测试上,并且只有当您发现您的代码在某个特定区域反复中断时才添加单元测试。
测试给了我们一定的自由,因为我们可以改变实现,而不必担心破坏接口。毕竟,它如何工作并不重要,重要的是它能工作。
TDD 可以提供自信地更好地重构代码所需的信心。更快,更干净,更好的结构-这不重要。
编码快乐!
使用 React、Jest 和 Enzyme 的测试驱动开发——第 1 部分
原文:https://testdriven.io/blog/tdd-with-react-jest-and-enzyme-part-one/
在这篇文章中,我们将使用测试驱动开发(TDD)结合 Jest 和 Enzyme 开发一个 React 应用程序。完成后,您将能够:
- 使用 TDD 开发一个 React 应用程序
- 用酶和 Jest 测试 React 应用程序
- 编写和使用 CSS 变量进行重用和响应性设计
- 创建一个可重用的 React 组件,根据所提供的道具以不同的方式呈现和运行
- 使用 React 属性类型对组件属性进行类型检查
- 从响应式设计的角度看应用程序
- 使用灵活框模块创建灵活布局
这篇文章假设你至少有 React 的基本知识。如果你完全是 React 新手,建议你完成 React 官方简介教程。
零件:
- Part 1 (本帖!):在第一部分,我们将建立整个项目,然后用测试驱动开发来开发 UI。
- 第二部分 :在这一部分,我们将在开始添加基本的计算器功能之前,通过添加数字和操作键来完成 UI。
我们将使用:
- React v17.02
- 节点 v14.16.1
其他依赖版本此处。
项目概述
我们将构建一个由四个 UI 组件组成的基本计算器应用程序。每个组件都有一组独立的测试,保存在相应的测试文件中。
什么是测试驱动开发?
测试驱动开发(TDD)是一种利用短开发周期重复的开发方法,称为红色 - 绿色 - 重构。
流程:
- 添加测试
- 运行所有测试,查看新测试是否失败(红色)
- 编写通过测试的代码(绿色)
- 运行所有测试
- 重构
- 重复
优点:
- 实施前设计
- 有助于防止未来的退化和错误
- 增加代码按预期工作的信心
缺点:
- 需要更长的开发时间(但从长远来看可以节省时间)
- 测试边缘案例很难
- 嘲笑、假装和磕碰都更加困难
设计过程
想想你对基本计算器的了解...
从视觉角度来看,一个基本的计算器:
- 可以用每个键(操作键)进行四种运算:加、减、乘、除
- 有 12 个更新显示的键:
0
到9
(数字键)、.
(小数)和ce
(退格键) - 有一个
=
(等于)键。
从功能角度来看:
-
单击数字键时,计算器会更新显示以反映新的显示值。
-
单击操作键时,计算器将所选操作符和当前显示值保存到存储器中,然后更新显示。
-
当点击提交(或“等于”)键时,计算器获取存储的值、存储的操作符以及显示器的当前值,并基于上述输入创建输出。
-
最后,根据我们上面确定的内容,我们知道我们将有三种类型的按键和三种不同的功能,分别对应于这些按键类型:
键类型 函数名 功能描述 数字键 updateDisplay
更新显示值并将其呈现给 DOM 操作员键 setOperator
将选择的运算符保存到组件状态对象 提交密钥 callOperator
处理数学运算
我们会有这些变量:
displayValue
-要显示的输入或计算值。numbers
-用于数字键的字符串值数组。operators
-用于操作键的字符串值数组。selectedOperator
-保存在存储器中的选定操作。storedValue
-保存在存储器中的输入或计算值。
至此,我们现在可以考虑我们的 React 组件了。将有四个与计算器相关的组件:
计算器组件
这是我们应用程序的主要 UI 状态组件。它呈现了Display
和Keypad
组件,包含了所有的应用程序功能以及应用程序的状态。
显示组件
这是一个无状态组件,它接收一个单独的属性displayValue
。
键盘组件
这也是一个无状态组件,包含所有的键。它接收以下道具:
callOperator
numbers
operators
setOperator
updateDisplay
关键组分
最后一个组件也是无状态的,它接收以下属性:
keyAction
-与按键类型相关的功能。keyType
-用于确定Key
将拥有哪些 CSS 规则的字符串。keyValue
-用于确定要传递给keyAction
函数的值的字符串。
入门指南
项目设置
从克隆初始项目存储库开始:
`$ git clone -b init [[email protected]](/cdn-cgi/l/email-protection):calebpollman/react-calculator.git
$ cd react-calculator
$ npm install
$ npm start`
使用非常有用的 Create React App 生成器初始化项目回购。
一个新的浏览器标签应该打开到 http://localhost:3000 ,DOM 的唯一内容是Hello World!
。一旦完成就杀死服务器。
因为我们使用 TDD 来开发 UI,所以对视图的更改会很慢。我们将专注于预先编写测试,UI 将在整个后期逐步完成。
测试配置
对于测试,我们将使用 Create React 应用程序附带的全功能测试解决方案 Jest ,以及 React 的一组强大测试实用程序 Enzyme 。
添加酶:
对于 react 应用版本 15.5 或更高版本,Enzyme 需要 react-test-renderer :
`$ npm i -D react-test-renderer @wojtekmaj/enzyme-adapter-react-17`
在“src”目录下添加一个名为 setupTests.js 的新文件:
`import { configure } from 'enzyme'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; configure({ adapter: new Adapter() });`
Create React App 在每次测试前都会运行 setupTests.js 文件,所以它会执行并正确配置 Enzyme。
配置字体和初始 CSS
导入应用程序字体
对于我们的应用程序字体,我们将使用Orbitron
,这是一种为显示器设计的字体,类似于您在技术先进的未来看到的东西,如果未来是 1983 年的话。我们需要两个权重,regular
(400)和bold
(700),我们将从 Google Fonts 加载字体。导航到“公共”目录中的index.html,并在 HTML 的head
中添加link
元素:
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link href="https://fonts.googleapis.com/css?family=Orbitron:400,700" rel="stylesheet">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
<title>Calcultronic 5000</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>`
编写 CSS 变量
接下来,我们将编写第一个变量和一个基本的 CSS 重置。因为我们希望变量对应用程序全局可用,所以我们将从:root
范围定义它们。定义变量的语法是使用自定义属性符号,每个符号都以--
开头,后跟变量名。让我们为应用程序字体编写一个变量,并根据需要继续更新变量。
导航到 index.css 文件并添加以下内容:
`/*
app variables
*/ :root { /* font */ --main-font: 'Orbitron', sans-serif; } /*
app CSS reset
*/ body, div, p { margin: 0; padding: 0; }`
然后我们需要将 CSS 导入到我们的应用程序中。在 index.js 中更新文件顶部的导入语句:
`import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
ReactDOM.render(
<div>Hello World!</div>,
document.getElementById('root')
);`
应用程序组件
浅层渲染测试
我们将开始构建每个组件,首先添加一个相应的测试文件,然后使用 Enzyme 进行浅层渲染测试。
浅层呈现测试有助于将组件作为一个单元进行测试,并避免间接测试子组件的行为。你可以在酵素文档中找到更多关于浅层渲染的信息。
编写App
浅渲染测试
首先为App
组件添加第一个失败的测试(红色),然后编写代码让它通过(绿色)。首先,在“src/components/App”中添加一个名为 App.spec.js 的新 spec 文件,并添加一个浅层渲染测试:
`import React from 'react'; import { shallow } from 'enzyme'; import App from './App'; describe('App', () => { it('should render a <div />', () => { const wrapper = shallow(<App />); expect(wrapper.find('div').length).toEqual(1); }); });`
运行测试:
一旦测试运行程序启动并运行,您的终端应该如下所示:
`FAIL src/components/App/App.spec.js
App
✕ should render a <div /> (58ms)
● App › should render a <div />
ReactShallowRenderer render(): Shallow rendering works only with custom components,
but the provided element type was `object`.
5 | describe('App', () => {
6 | it('should render a <div />', () => {
> 7 | const wrapper = shallow(<App />);
| ^
8 | expect(wrapper.find('div').length).toEqual(1);
9 | });
10 | });
at ReactShallowRenderer.render (node_modules/react-test-renderer/cjs/react-test-renderer-shallow.development.js:786:15)
at fn (node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:668:53)
at withSetStateAllowed (node_modules/enzyme-adapter-utils/src/Utils.js:99:18)
at Object.render (node_modules/enzyme-adapter-react-16/src/ReactSixteenAdapter.js:668:18)
at new ShallowWrapper (node_modules/enzyme/src/ShallowWrapper.js:397:22)
at shallow (node_modules/enzyme/src/shallow.js:10:10)
at Object.<anonymous> (src/components/App/App.spec.js:7:21)
console.error node_modules/react/cjs/react.development.js:167
Warning: React.createElement: type is invalid -- expected a string (for built-in components)
or a class/function (for composite components) but got: object. You likely forgot to export your
component from the file it's defined in, or you might have mixed up default and named imports.
Check your code at App.spec.js:7.
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total
Snapshots: 0 total
Time: 1.999s, estimated 2s
Ran all test suites.`
测试失败,因为App
组件尚未写入。
创建应用程序组件
继续通过创建App
组件来通过测试。导航到 App.jsx 并添加以下代码:
`import React from 'react';
const App = () => <div className="app-container" />;
export default App;`
运行测试:
第一个测试现在应该通过了:
`PASS src/components/App/App.spec.js
App
✓ should render a <div /> (9ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.369s
Ran all test suites related to changed files.`
您可能已经注意到,如果您没有退出测试运行程序,它仍然在命令行上运行。只要它在运行,它就会继续监视项目,并在文件发生变化时运行测试。您可以在继续学习本教程时让它运行,也可以退出并在空闲时运行它。
添加应用 CSS
现在我们的第一个测试已经通过了,让我们给App
组件添加一些样式。由于它是应用程序其余部分的包装器,我们将使用它来设置应用程序的窗口大小,并使用flexbox
模块将App
的内容(即Calculator
组件)水平和垂直居中。
导航到“src/components/App”目录中的 App.css 并添加以下类:
`.app-container { height: 100vh; width: 100vw; align-items: center; display: flex; justify-content: center; }`
关于这些 CSS 属性:
height: 100vh;
将应用高度设置为浏览器窗口视图高度的 100%。width: 100vw;
将应用程序宽度设置为浏览器窗口视图宽度的 100%。- 如果
display
属性被设置为flex
,那么align-items: center;
垂直对齐 flex-container 内部的内容。display: flex;
设置App
类使用flexbox
模块。- 如果
display
属性设置为flex
,则justify-content: center;
水平对齐 flex-container 内的内容。
将 CSS 导入到App
:
`import React from 'react'; import './App.css'; const App = () => <div className="app-container" />; export default App;`
将App
导入 index.js :
`import React from 'react'; import ReactDOM from 'react-dom'; import App from './components/App/App'; import './index.css'; ReactDOM.render( <App />, document.getElementById('root') );`
计算器组件
检查应用程序中的计算器
因为App
组件将包含Calculator
组件,所以让我们编写一个测试来检查App
中是否存在Calculator
组件。这个测试将使用containsMatchingElement
,一个酶方法,根据 React 元素是否匹配渲染树中的元素返回true
或false
。
我们还应该重构文件以使用beforeEach
,这是 Jest 中的一个设置方法,用于减少测试中的样板文件。顾名思义,任何放在beforeEach
中的代码都会在每个it
块之前执行。我们将在beforeEach
之外创建wrapper
对象,使其可以被测试访问。
添加测试并重构 App.spec.js ,确保导入文件顶部的Calculator
组件:
`import React from 'react'; import { shallow } from 'enzyme'; import App from './App'; import Calculator from '../Calculator/Calculator'; describe('App', () => { let wrapper; beforeEach(() => wrapper = shallow(<App />)); it('should render a <div />', () => { expect(wrapper.find('div').length).toEqual(1); }); it('should render the Calculator Component', () => { expect(wrapper.containsMatchingElement(<Calculator />)).toEqual(true); }); });`
该测试将失败,因为Calculator
组件不存在:
`FAIL src/components/App/App.spec.js
App
✓ should render a <div /> (9ms)
✕ should render the Calculator Component (7ms)
● App › should render the Calculator Component
expect(received).toEqual(expected) // deep equality
Expected: true
Received: false
13 |
14 | it('should render the Calculator Component', () => {
> 15 | expect(wrapper.containsMatchingElement(<Calculator />)).toEqual(true);
| ^
16 | });
17 | });
18 |
at Object.<anonymous> (src/components/App/App.spec.js:15:61)
console.error node_modules/react/cjs/react.development.js:167
Warning: React.createElement: type is invalid -- expected a string (for built-in components)
or a class/function (for composite components) but got: object. You likely forgot to export your
component from the file it's defined in, or you might have mixed up default and named imports.
Check your code at App.spec.js:15.
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 passed, 2 total
Snapshots: 0 total
Time: 2.803s
Ran all test suites related to changed files.`
写计算器浅渲染测试
在我们编写通过App › should render the Calculator Component
测试的Calculator
组件之前,添加Calculator
测试文件,并在新的测试文件中设置一个浅层渲染测试,就像我们对App
组件所做的那样。
创建 Calculator.spec.js ,将浅层渲染测试和beforeEach
设置方法添加到文件中:
`import React from 'react'; import { shallow } from 'enzyme'; import Calculator from './Calculator'; describe('Calculator', () => { let wrapper; beforeEach(() => wrapper = shallow(<Calculator />)); it('should render a <div />', () => { expect(wrapper.find('div').length).toEqual(1); }); });`
该测试将立即失败。
创建计算器组件
我们的应用程序主要由无状态的组件组成,但是Calculator
将是有状态的,所以我们可以利用 React 的内部应用程序状态。
有状态的组件是基于类的,允许我们设置可变的组件状态变量,这些变量可以作为
props
传递给子组件。
导航到 Calculator.jsx 并定义初始状态变量和方法,这些在文章的设计过程部分已经讨论过了:
`import React, { Component } from 'react';
class Calculator extends Component {
state = {
// value to be displayed in <Display />
displayValue: '0',
// values to be displayed in number <Keys />
numbers: [],
// values to be displayed in operator <Keys />
operators: [],
// operator selected for math operation
selectedOperator: '',
// stored value to use for math operation
storedValue: '',
}
callOperator = () => {
console.log('call operation');
}
setOperator = () => {
console.log('set operation');
}
updateDisplay = () => {
console.log('update display');
}
render = () => {
return (
<div className="calculator-container" />
);
}
}
export default Calculator;`
这通过了Calculator › should render a <div />
测试,但没有通过App › should render the Calculator Component
。为什么?因为App
组件还没有更新为包含Calculator
组件。让我们现在做那件事。
在 App.jsx 中,将代码更新如下:
`import React from 'react'; import Calculator from '../Calculator/Calculator'; import './App.css'; const App = () => ( <div className="app-container"> <Calculator /> </div> ); export default App;`
随着Calculator
组件的创建,所有测试现在都通过了:
`PASS src/components/Calculator/Calculator.spec.js
PASS src/components/App/App.spec.js
Test Suites: 2 passed, 2 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 2.5s
Ran all test suites related to changed files.`
为应用程序添加快照测试
虽然快照并不是 TDD 的一部分,因为它们是在组件被写入之后被写入的(想想“绿色 - 绿色 - 重构”而不是“红色 - 绿色 - 重构”),但是它们是值得包含在内的,因为它们会迅速提醒您所呈现组件的任何意外变化。最好在完成组件编写后再添加它们。
来自 Jest 文档:
一个典型的移动应用程序快照测试用例呈现一个 UI 组件,截取一个屏幕截图,然后将其与测试中存储的参考图像进行比较。如果两个图像不匹配,测试将失败:要么是意外的更改,要么是屏幕截图需要更新到 UI 组件的新版本。
您将需要为 Jest 版本 24 或更高版本使用快照序列化程序。我们将使用酶转 json :
`$ npm i -D enzyme-to-json`
导航到 App.spec.js 并将toMatchSnapshot
添加为文件中的第一个测试,就在beforeEach
之后:
`... describe('App', () => { let wrapper; beforeEach(() => wrapper = shallow(<App />)); it('should render correctly', () => expect(wrapper).toMatchSnapshot()); ... });`
当我们完成每个组件的 UI 时,我们将添加一个快照测试作为每个
spec
文件中的第一个测试。这创建了一个模式,将出现在每个spec
文件中的通用测试(快照、浅层渲染)放置在组件特定测试之上。
新的快照测试立即通过,并且它将继续通过,直到在那个组件中有一个 UI 变化。这也为App
组件创建了一个“snapshots”目录以及一个名为 App.spec.js.snap 的文件。
`PASS src/components/Calculator/Calculator.spec.js
PASS src/components/App/App.spec.js
› 1 snapshot written.
Snapshot Summary
› 1 snapshot written from 1 test suite.
Test Suites: 2 passed, 2 total
Tests: 4 passed, 4 total
Snapshots: 1 written, 1 total
Time: 1.642s, estimated 2s
Ran all test suites.`
现在我们可以添加Calculator
样式了。
添加计算器 CSS
首先用与Calculator
相关的变量更新 CSS 变量,并添加一个媒体查询。由于应用程序的最小视觉设计,我们只使用一个媒体查询来更新字体大小,并为平板电脑或更小的设备删除Calculator
组件周围的边距。
导航到 index.css 并更新文件,如下所示:
`/*
app variables
*/ :root { /* background colors */ --calculator-background-color: #696969; /* font */ --main-font: 'Orbitron', sans-serif; /* calculator dimensions */ --calculator-height: 72%; --calculator-width: 36%; } /*
media query for tablet or smaller screen
*/ @media screen and (max-width: 1024px) { :root { /* calculator dimensions */ --calculator-height: 100%; --calculator-width: 100%; } } /*
app CSS reset
*/ body, div, p { margin: 0; padding: 0; }`
接下来更新 Calculator.css 中的组件 CSS:
`.calculator-container { background-color: var(--calculator-background-color); height: var(--calculator-height); width: var(--calculator-width); }`
然后将 CSS 文件导入到 Calculator.jsx 的顶部:
`import './Calculator.css';`
我们现在有了第一个呈现到 DOM 的组件!通过运行应用程序启动浏览器:
然后打开你的浏览器(如果还没有自动打开的话)到 http://localhost:3000 。DOM 应该与这个截图匹配:
现在是停下来回顾我们迄今所做的一切的好时机。也尝试一下 CSS。
显示组件
检查计算器中的显示
因为Calculator
组件将包含Display
和Keypad
组件,下一步是编写一个测试来检查Display
组件在Calculator
中的存在。
将测试添加到 Calculator.spec.js :
`it('should render the Display Component', () => { expect(wrapper.containsMatchingElement(<Display />)).toEqual(true); });`
确保导入文件顶部的Display
组件:
`import Display from '../Display/Display';`
与之前的containsMatchingElement
测试一样,它将失败,因为Display
组件不存在。
在我们编写Display
组件之前,添加Display
测试文件,并在新的测试文件中设置一个浅层渲染测试,就像我们对Calculator
组件所做的那样。
创建,然后导航到 Display.spec.js ,添加浅层渲染测试以及beforeEach
设置方法:
`import React from 'react'; import {shallow} from 'enzyme'; import Display from './Display'; describe('Display', () => { let wrapper; beforeEach(() => wrapper = shallow(<Display />)); it('should render a <div />', () => { expect(wrapper.find('div').length).toEqual(1); }); });`
这也将失败,因为Display
组件仍然不存在。
在 Display.jsx 中添加组件,并在文件顶部导入prop-types
:
`import React from 'react';
import PropTypes from 'prop-types';
const Display = ({ displayValue }) => <div className="display-container" />;
Display.propTypes = { displayValue: PropTypes.string.isRequired };
export default Display;`
prop-types 允许我们记录传递给组件的预期属性类型,并在开发过程中,如果传递给组件的类型与包含在
ComponentName.propTypes
对象中的 props 不匹配,抛出警告。
将组件添加到 Display.jsx 将通过Display
浅层渲染测试,但带有一个prop-type
警告。尽管如此,Calculator › should render the Display component
测试仍然会失败:
`PASS src/components/App/App.spec.js
PASS src/components/Display/Display.spec.js
● Console
console.error node_modules/prop-types/checkPropTypes.js:20
Warning: Failed prop type: The prop `displayValue` is marked as required in `Display`,
but its value is `undefined`.
in Display (at Display.spec.js:8)
FAIL src/components/Calculator/Calculator.spec.js
● Console
console.error node_modules/prop-types/checkPropTypes.js:20
Warning: Failed prop type: The prop `displayValue` is marked as required in `Display`,
but its value is `undefined`.
in Display (at Calculator.spec.js:16)
● Calculator › should render the Display Component
expect(received).toEqual(expected) // deep equality
Expected: true
Received: false
14 |
15 | it('should render the Display Component', () => {
> 16 | expect(wrapper.containsMatchingElement(<Display />)).toEqual(true);
| ^
17 | });
18 | });
19 |
at Object.<anonymous> (src/components/Calculator/Calculator.spec.js:16:58)
Test Suites: 1 failed, 2 passed, 3 total
Tests: 1 failed, 5 passed, 6 total
Snapshots: 1 passed, 1 total
Time: 2.583s`
我们需要在 Calculator.jsx 中导入并添加Display
组件,然后更新渲染方法,以便我们将displayValue
道具传递给Display
:
`import React, { Component } from 'react';
import Display from '../Display/Display';
import './Calculator.css';
class Calculator extends Component {
...
render = () => {
// unpack the component state by using Object Destructuring
const { displayValue } = this.state;
return (
<div className="calculator-container">
<Display displayValue={displayValue} />
</div>
);
}
}
...`
在 Display.spec.js 中,使用空字符串作为值,将displayValue
属性添加到beforeEach
块中:
`... describe('Display', () => { let wrapper; beforeEach(() => wrapper = shallow(<Display displayValue={''} />)); ... }); ...`
然后更新Calculator › should render the Display component
测试,以说明Display
中所需的道具。我们可以通过对wrapper
对象使用instance
方法来访问Calculator
的状态变量和方法。
在 Calculator.spec.js 中更新测试:
`it('should render the Display Component', () => { expect(wrapper.containsMatchingElement( <Display displayValue={wrapper.instance().state.displayValue} /> )).toEqual(true); });`
所有测试都应该通过!
`PASS src/components/Calculator/Calculator.spec.js
PASS src/components/Display/Display.spec.js
PASS src/components/App/App.spec.js
Test Suites: 3 passed, 3 total
Tests: 6 passed, 6 total
Snapshots: 1 passed, 1 total
Time: 1.964s, estimated 2s
Ran all test suites.`
显示渲染显示值
接下来,让我们测试实际displayValue
的渲染,这样我们的计算器就会显示一些东西。
首先在 Display.spec.js 中编写一个测试:
`it('renders the value of displayValue', () => { wrapper.setProps({ displayValue: 'test' }); expect(wrapper.text()).toEqual('test'); });`
我们将再次在控制台中进行失败的测试:
`PASS src/components/App/App.spec.js
PASS src/components/Calculator/Calculator.spec.js
FAIL src/components/Display/Display.spec.js
● Display › renders the value of displayValue
expect(received).toEqual(expected) // deep equality
Expected: "test"
Received: ""
14 | it('renders the value of displayValue', () => {
15 | wrapper.setProps({ displayValue: 'test' });
> 16 | expect(wrapper.text()).toEqual('test');
| ^
17 | });
18 | });
19 |
at Object.<anonymous> (src/components/Display/Display.spec.js:16:28)
Test Suites: 1 failed, 2 passed, 3 total
Tests: 1 failed, 6 passed, 7 total
Snapshots: 1 passed, 1 total
Time: 2.489s
Ran all test suites.`
我们需要重构 Display.jsx 来呈现displayValue
的值。让我们也添加一些className
到我们的 HTML 元素中,为添加样式做准备:
`...
const Display = ({ displayValue }) => (
<div className="display-container">
<p className="display-value">
{displayValue}
</p>
</div>
);
...`
注意,我们使用括号来扩展 arrow 函数的隐式返回功能。
测试和测试套件都应该是绿色的!
为显示添加快照测试
完成我们的组件后,我们可以导航到 Display.spec.js 并将toMatchSnapshot
添加为文件中的第一个测试,就在beforeEach
之后:
`... describe('Display', () => { ... it('should render correctly', () => expect(wrapper).toMatchSnapshot()); ... });`
添加显示 CSS
按照我们在前面的组件中使用的添加 CSS 的相同模式,首先更新 index.css 中的变量和媒体查询:
`/*
app variables
*/ :root { /* background colors */ --display-background-color: #1d1f1f; /* font */ --main-font: 'Orbitron', sans-serif; /* font colors */ --display-text-color: #23e000; /* font sizes */ --display-text-size: 4em; /* font weights */ --display-text-weight: 400; /* calculator dimensions */ --calculator-height: 72%; --calculator-width: 36%; /* display dimensions */ --display-height: 24%; --display-width: 92%; } /*
media query for tablet or smaller screen
*/ @media screen and (max-width: 1024px) { :root { /* font sizes */ --display-text-size: 10em; /* calculator dimensions */ --calculator-height: 100%; --calculator-width: 100%; } } /*
app CSS reset
*/ body, div, p { margin: 0; padding: 0; }`
然后在 Display.css 中添加组件 CSS:
`.display-container { align-items: center; background: var(--display-background-color); display: flex; height: var(--display-height); padding: 0 4%; width: var(--display-width); } .display-value { color: var(--display-text-color); font-size: var(--display-text-size); font-family: var(--main-font); font-weight: var(--display-text-weight); margin-left: auto; overflow: hidden; }`
关于这些 CSS 属性:
margin-left: auto;
将元素推到容器的右边缘。overflow: hidden;
指定如果 HTML 大于容器,溢出将被隐藏。
并将 CSS 文件导入到 Display.jsx :
`import React from 'react';
import PropTypes from 'prop-types';
import './Display.css';
...`
现在我们已经完成了Display
的 CSS,让我们启动浏览器,看看输出!
输出应该与该屏幕截图相匹配:
Display
组件现在呈现在浏览器中,我们准备继续测试和编写Keypad
组件。
键盘组件
添加键盘组件和测试
现在我们已经构建好了Display
组件,我们需要将Keypad
组件添加到Calculator
中。我们将从在Calculator
组件测试中测试它开始。
在 Calculator.spec.js 中重构Calculator › should render the Display component
测试:
`it('should render the Display and Keypad Components', () => {
expect(wrapper.containsAllMatchingElements([
<Display displayValue={wrapper.instance().state.displayValue} />,
<Keypad
callOperator={wrapper.instance().callOperator}
numbers={wrapper.instance().state.numbers}
operators={wrapper.instance().state.operators}
setOperator={wrapper.instance().setOperator}
updateDisplay={wrapper.instance().updateDisplay}
/>
])).toEqual(true);
});`
containsAllMatchingElements
获取一个元素数组,如果在 DOM 树中找到了所有元素,则返回true
。
确保在Keypad
组件中导入:
`import Keypad from '../Keypad/Keypad';`
我们的新测试失败了!Keypad
组件尚不存在。
在我们添加组件之前,遵循我们对Display
组件使用的模式:
- 在“src/components/Keypad”中创建规格文件 Keypad.spec.js
- 添加
Keypad
浅渲染测试
`import React from 'react';
import { shallow } from 'enzyme';
import Keypad from './Keypad';
describe('Keypad', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(
<Keypad
callOperator={jest.fn()}
numbers={[]}
operators={[]}
setOperator={jest.fn()}
updateDisplay={jest.fn()}
/>
);
});
it('should render a <div />', () => {
expect(wrapper.find('div').length).toEqual(1);
});
});`
因为我们直接从它的文件中呈现
Keypad
,所以它不能访问Calculator
方法。代替这些方法,我们使用jest.fn()
,一个创建模拟函数的 Jest 函数。更多信息这里。
如果您检查控制台,您应该看到两个测试套件失败。现在将 JSX 添加到 Keypad.jsx :
`import React from 'react';
import PropTypes from 'prop-types';
const Keypad = ({ callOperator, numbers, operators, setOperator, updateDisplay }) =>
<div className="keypad-container" />;
Keypad.propTypes = {
callOperator: PropTypes.func.isRequired,
numbers: PropTypes.array.isRequired,
operators: PropTypes.array.isRequired,
setOperator: PropTypes.func.isRequired,
updateDisplay: PropTypes.func.isRequired,
}
export default Keypad;`
导入 Calculator.jsx 中的Keypad
:
`import Keypad from '../Keypad/Keypad';`
然后,将Keypad
添加到render
方法中,确保从this.state
解包numbers
和operators
的值,并将所有需要的道具传递给Keypad
:
`render = () => {
// unpack the component state by using Object Destructuring
const { displayValue, numbers, operators } = this.state;
return (
<div className="calculator-container">
<Display displayValue={displayValue} />
<Keypad
callOperator={this.callOperator}
numbers={numbers}
operators={operators}
setOperator={this.setOperator}
updateDisplay={this.updateDisplay}
/>
</div>
);
}`
所有测试都应该通过。
计算器快照
添加Calculator
快照,现在我们已经完成了组件的 UI,就在 Calculator.spec.js 中的beforeEach
下面:
`it('should render correctly', () => expect(wrapper).toMatchSnapshot());`
`PASS src/components/App/App.spec.js
PASS src/components/Keypad/Keypad.spec.js
PASS src/components/Display/Display.spec.js
PASS src/components/Calculator/Calculator.spec.js
› 1 snapshot written.
Snapshot Summary
› 1 snapshot written from 1 test suite.
Test Suites: 4 passed, 4 total
Tests: 10 passed, 10 total
Snapshots: 1 written, 2 passed, 3 total
Time: 2.726s
Ran all test suites related to changed files.`
下次
我们将在这里休息一下,在下一部分继续,从测试包含在Keypad
中的numbers
和operators
数组中的值的呈现开始。然后我们将继续测试Key
组件,继续应用程序事件和功能测试,然后做一些最终的重构。
如果你想跳过前面,你可以从 GitHub 上的 react-calculator repo 获取最终代码。
干杯!
part 2可用!
使用 React、Jest 和 Enzyme 的测试驱动开发——第 2 部分
原文:https://testdriven.io/blog/tdd-with-react-jest-and-enzyme-part-two/
这是使用 React、Jest 和 Enzyme 的测试驱动开发的第二部分。你可以在这里找到第一部。
上一次,我们从项目概述开始,其中包括对测试驱动开发(TDD)的简要说明、应用程序设计过程以及应用程序组件的高级概要。从那里我们继续项目设置,开始编写我们的(失败)测试,然后是通过这些测试的代码,最终以我们的Calculator
快照结束。至此,我们已经完成了Calculator
和Display
组件的 UI,并且已经开始了Keypad
组件的工作。
零件:
- 第 1 部分 :在第一部分,我们将建立整个项目,然后用测试驱动开发来开发 UI。
- 第二部(本帖!):在这一部分中,我们将在开始添加基本的计算器功能之前,通过添加数字和操作键来结束 UI。
让我们通过测试numbers
和operators
的Keypad
渲染,回到红色、绿色、重构的循环中来!
键盘组件
测试数字和运算符在键盘中的呈现
按照我们测试Display
组件中displayValue
道具的渲染的相同方式,让我们为Keypad
组件中的numbers
和operators
道具编写渲染测试。
在 Keypad.spec.js 中,从numbers
测试开始:
`it('renders the values of numbers', () => { wrapper.setProps({numbers: ['0', '1', '2']}); expect(wrapper.find('.numbers-container').text()).toEqual('012'); });`
然后更新 Keypad.jsx ,通过添加一个map
函数来遍历numbers
数组以及一个容器div
元素来容纳我们的新元素,从而通过测试:
`...
const Keypad = ({
callOperator,
numbers,
operators,
setOperator,
updateDisplay,
}) => {
const numberKeys = numbers.map(number => <p key={number}>{number}</p>);
return (
<div className="keypad-container">
<div className="numbers-container">
{numberKeys}
</div>
</div>
);
}
...`
现在Keypad › should render a <div />
应该断开,因为有不止一个div
。
更新 Keypad.spec.js 中的测试:
`it('should render 2 <div />\'s', () => { expect(wrapper.find('div').length).toEqual(2); });`
全部通过!在 Keypad.spec.js 中对operators
遵循相同的模式:
`it('renders the values of operators', () => { wrapper.setProps({operators: ['+', '-', '*', '/']}); expect(wrapper.find('.operators-container').text()).toEqual('+-*/'); });`
然后在 Keypad.jsx 中,用我们对numbers
所做的同样方式更新组件:
`...
const Keypad = ({
callOperator,
numbers,
operators,
setOperator,
updateDisplay,
}) => {
const numberKeys = numbers.map(number => <p key={number}>{number}</p>);
const operatorKeys = operators.map(operator => <p key={operator}>{operator}</p>);
return (
<div className="keypad-container">
<div className="numbers-container">
{numberKeys}
</div>
<div className="operators-container">
{operatorKeys}
</div>
</div>
);
}
...`
这个现在应该破Keypad › should render 2 <div />'s
。更新 Keypad.spec.js 中的测试:
`it('should render 3 <div />\'s', () => { expect(wrapper.find('div').length).toEqual(3); });`
测试是绿色的!
添加键盘 CSS
现在添加 CSS 变量和组件 CSS。导航到 index.css 并对:root
范围进行更新:
`/*
app variables
*/ :root { /* background colors */ --calculator-background-color: #696969; --display-background-color: #1d1f1f; /* font */ --main-font: 'Orbitron', sans-serif; /* font colors */ --display-text-color: #23e000; /* font sizes */ --display-text-size: 4em; /* font weights */ --display-text-weight: 400; /* calculator dimensions */ --calculator-height: 72%; --calculator-width: 36%; /* display dimensions */ --display-height: 24%; --display-width: 92%; /* keypad dimensions */ --keypad-height: 72%; --keypad-width: 96%; } /*
media query for tablet or smaller screen
*/ @media screen and (max-width: 1024px) { :root { /* font sizes */ --display-text-size: 6em; /* calculator dimensions */ --calculator-height: 100%; --calculator-width: 100%; } }`
将以下内容添加到 Keypad.css :
`.keypad-container { display: flex; flex-direction: row; flex-wrap: wrap; height: var(--keypad-height); padding: 2%; width: var(--keypad-width); } .numbers-container { display: flex; flex-direction: row; flex-wrap: wrap; height: 80%; width: 75%; } .operators-container { display: flex; flex-direction: column; height: 80%; width: 25%; } .submit-container { height: 20%; width: 100%; }`
关于这些 CSS 属性:
flex-direction: row;
将flex-container
中内容的布局设置为row
(这是display: flex
的默认方向)。flex-wrap: wrap;
通知flex-container
将超过flex-container
宽度的内容打包到flex-container
中。flex-direction: column;
将flex-container
中内容的布局设置为column
。
最后,将 Keypad.css 导入到 Keypad.jsx 中:
`import React from 'react';
import PropTypes from 'prop-types';
import './Keypad.css';
...`
启动应用程序:
浏览器现在应该看起来像这样:
关键组分
检查键盘中的钥匙
按照我们对Calculator
、Display
和Keypad
组件使用的相同的浅层渲染测试模式,我们现在将检查Keypad
中是否存在Key
组件。
将以下测试添加到 Keypad.spec.js :
`it('should render an instance of the Key component', () => { expect(wrapper.find('Key').length).toEqual(1); });`
您可能已经注意到,在之前的测试中,我们在检查子组件时使用了
containsMatchingElement
。因为我们将渲染 17 个不同的Key
元素,每个元素都有不同的keyAction
、keyType
和keyValue
,使用containsMatchingElement
对这个例子不起作用。相反,我们将使用find
方法检查元素的存在,然后检查结果数组的长度。
在“src/components/Key”中为Key
组件创建测试套件文件,然后在 Key.spec.js 中为Key
添加浅层渲染测试:
`import React from 'react';
import { shallow } from 'enzyme';
import Key from './Key';
describe('Key', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(
<Key
keyAction={jest.fn()}
keyType={''}
keyValue={''}
/>
);
});
it('should render a <div />', () => {
expect(wrapper.find('div').length).toEqual(1);
});
});`
将组件添加到 Key.jsx :
`import React from 'react';
import PropTypes from 'prop-types';
const Key = ({ keyAction, keyType, keyValue }) => <div className="key-container" />;
Key.propTypes = {
keyAction: PropTypes.func.isRequired,
keyType: PropTypes.string.isRequired,
keyValue: PropTypes.string.isRequired,
}
export default Key;`
Keypad › should render an instance of the Key component
应该还是失败了。
`PASS src/components/Display/Display.spec.js
PASS src/components/Calculator/Calculator.spec.js
PASS src/components/App/App.spec.js
FAIL src/components/Keypad/Keypad.spec.js
● Keypad › should render an instance of the Key component
expect(received).toEqual(expected) // deep equality
Expected: 1
Received: 0
33 |
34 | it('should render an instance of the Key component', () => {
> 35 | expect(wrapper.find('Key').length).toEqual(1);
| ^
36 | });
37 | });
38 |
at Object.<anonymous> (src/components/Keypad/Keypad.spec.js:35:40)
PASS src/components/Key/Key.spec.js
Test Suites: 1 failed, 4 passed, 5 total
Tests: 1 failed, 13 passed, 14 total
Snapshots: 3 passed, 3 total
Time: 3.069s
Ran all test suites related to changed files.`
导入 Keypad.jsx 中的Key
组件,更新return
语句:
`...
import Key from '../Key/Key';
import './Keypad.css';
const Keypad = ({
callOperator,
numbers,
operators,
setOperator,
updateDisplay,
}) => {
...
return (
<div className="keypad-container">
<div className="numbers-container">
{numberKeys}
</div>
<div className="operators-container">
{operatorKeys}
</div>
<Key
keyAction={callOperator}
keyType=""
keyValue=""
/>
</div>
);
}
...`
测试应该会通过。
键呈现键值
接下来,向 Key.spec.jsx 添加一个新的测试,检查keyValue
的值是否存在:
`it('should render the value of keyValue', () => { wrapper.setProps({ keyValue: 'test' }); expect(wrapper.text()).toEqual('test'); });`
重构 Key.jsx 中的Key
组件:
`const Key = ({ keyAction, keyType, keyValue }) => (
<div className="key-container">
<p className="key-value">
{keyValue}
</p>
</div>
);`
全部通过!
添加关键 CSS
这是更新 CSS 变量和添加Key
CSS 的好地方。导航到 index.css 并进行以下更新:
`:root { /* background colors */ --action-key-color: #545454; --action-key-color-hover: #2a2a2a; --calculator-background-color: #696969; --display-background-color: #1d1f1f; --number-key-color: #696969; --number-key-color-hover: #3f3f3f; --submit-key-color: #d18800; --submit-key-color-hover: #aa6e00; ... /* font colors */ --display-text-color: #23e000; --key-text-color: #d3d3d3; /* font sizes */ --display-text-size: 4em; --key-text-size: 3em; /* font weights */ --display-text-weight: 400; --key-text-weight: 700; ... } ... @media screen and (max-width: 1024px) { :root { /* font sizes */ --display-text-size: 10em; --key-text-size: 6em; ... } }`
完整的 index.css 文件现在应该是这样的:
`/*
app variables
*/ :root { /* background colors */ --action-key-color: #545454; --action-key-color-hover: #2a2a2a; --calculator-background-color: #696969; --display-background-color: #1d1f1f; --number-key-color: #696969; --number-key-color-hover: #3f3f3f; --submit-key-color: #d18800; --submit-key-color-hover: #aa6e00; /* font */ --main-font: 'Orbitron', sans-serif; /* font colors */ --display-text-color: #23e000; --key-text-color: #d3d3d3; /* font sizes */ --display-text-size: 4em; --key-text-size: 3em; /* font weights */ --display-text-weight: 400; --key-text-weight: 700; /* calculator dimensions */ --calculator-height: 72%; --calculator-width: 36%; /* display dimensions */ --display-height: 24%; --display-width: 92%; /* keypad dimensions */ --keypad-height: 72%; --keypad-width: 96%; } /*
media query for tablet or smaller screen
*/ @media screen and (max-width: 1024px) { :root { /* font sizes */ --display-text-size: 10em; --key-text-size: 6em; /* calculator dimensions */ --calculator-height: 100%; --calculator-width: 100%; } } /*
app CSS reset
*/ body, div, p { margin: 0; padding: 0; }`
然后在 Key.css 中添加组件 CSS:
`.key-container { align-items: center; display: flex; height: 25%; justify-content: center; transition: background-color 0.3s linear; } .key-container:hover { cursor: pointer; } .operator-key { background-color: var(--action-key-color); width: 100%; } .operator-key:hover { background-color: var(--action-key-color-hover); } .number-key { background-color: var(--number-key-color); width: calc(100%/3); } .number-key:hover { background-color: var(--number-key-color-hover); } .submit-key { background-color: var(--submit-key-color); height: 100%; width: 100%; } .submit-key:hover { background-color: var(--submit-key-color-hover); } .key-value { color: var(--key-text-color); font-family: var(--main-font); font-size: var(--key-text-size); font-weight: var(--key-text-weight); }`
属性用来给我们的
hover
效果一个非悬停和悬停背景色之间的平滑动画。第一个参数(background-color
)定义了要过渡的属性,第二个参数(0.3s
)指定了以秒为单位的过渡长度,第三个参数(linear
)是过渡动画的样式。
最后,导入 CSS 并在 Key.jsx 中进行上述更新:
`import React from 'react';
import PropTypes from 'prop-types';
import './Key.css';
const Key = ({ keyAction, keyType, keyValue }) => (
<div className={`key-container ${keyType}`}>
<p className="key-value">
{keyValue}
</p>
</div>
);
...`
为键添加快照测试
组件 UI 完成后,我们可以添加快照测试。在 Key.spec.js 中的测试顶部,添加:
`it('should render correctly', () => expect(wrapper).toMatchSnapshot());`
同样,该测试将立即通过,并且将继续通过,直到对
Key
组件 UI 进行了更改。
重构键盘以使用数字、运算符和提交键
因为我们想要为numbers
和operators
数组的每个索引以及submit
键呈现一个Key
组件,所以重构 Keypad.spec.js 中的Keypad › should render an instance of the Key component
测试:
`it('should render an instance of the Key component for each index of numbers, operators, and the submit Key', () => { const numbers = ['0', '1']; const operators = ['+', '-']; const submit = 1; const keyTotal = numbers.length + operators.length + submit; wrapper.setProps({ numbers, operators }); expect(wrapper.find('Key').length).toEqual(keyTotal); });`
重构 Keypad.jsx 的return
语句中的地图函数和Key
组件:
`...
const Keypad = ({
callOperator,
numbers,
operators,
setOperator,
updateDisplay,
}) => {
const numberKeys = numbers.map(number => (
<Key
key={number}
keyAction={updateDisplay}
keyType="number-key"
keyValue={number}
/>)
);
const operatorKeys = operators.map(operator => (
<Key
key={operator}
keyAction={setOperator}
keyType="operator-key"
keyValue={operator}
/>)
);
return (
<div className="keypad-container">
<div className="numbers-container">
{numberKeys}
</div>
<div className="operators-container">
{operatorKeys}
</div>
<div className="submit-container">
<Key
keyAction={callOperator}
keyType="submit-key"
keyValue="="
/>
</div>
</div>
);
}
...`
重构之后,Keypad › should render the Key component for each index of numbers, operators, and the submit Key
通过了,但是下面的测试失败了:
Keypad › renders the values of numbers
Keypad › renders the values of operators
如果您检查测试运行程序,Keypad › renders the values of operators
失败应该是这样的:
`● Keypad › renders the values of operators
expect(received).toEqual(expected) // deep equality
Expected: "+-*/"
Received: "<Key /><Key /><Key /><Key />"`
这是因为shallow
渲染方法只深入一层,并返回浅渲染的组件内容,而不是子组件的实际渲染内容。换句话说,当这些测试使用find
时,返回的内容只是Key
元素,而不是Key
中的实际内容。对于这个功能,我们可以使用 Enzyme mount
,它可以进行完整的 DOM 渲染,并允许我们获取子元素的文本值。我们将把这些测试移到它们自己的describe
语句中,以防止对shallow
的不必要调用。
作为编写渲染测试的规则:
- 总是从
shallow
(浅渲染)开始- 当您想要测试以下任一项时,使用
mount
:
componentDidMount
或componentDidUpdate
- DOM 呈现、组件生命周期和子组件的行为
另外,Keypad › should render 3 <div />'s
失败是因为我们添加了另一个容器div
。
更新 Keypad.spec.js 这样:
`import React from 'react';
import { mount, shallow } from 'enzyme';
import Keypad from './Keypad';
import Key from '../Key/Key';
describe('Keypad', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(
<Keypad
callOperator={jest.fn()}
numbers={[]}
operators={[]}
setOperator={jest.fn()}
updateDisplay={jest.fn()}
/>
);
});
it('should render 4 <div />\'s', () => {
expect(wrapper.find('div').length).toEqual(4);
});
it('should render an instance of the Key component for each index of numbers, operators, and the submit Key', () => {
const numbers = ['0', '1'];
const operators = ['+', '-'];
const submit = 1;
const keyTotal = numbers.length + operators.length + submit;
wrapper.setProps({ numbers, operators });
expect(wrapper.find('Key').length).toEqual(keyTotal);
});
});
describe('mounted Keypad', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(
<Keypad
callOperator={jest.fn()}
numbers={[]}
operators={[]}
setOperator={jest.fn()}
updateDisplay={jest.fn()}
/>
);
});
it('renders the values of numbers to the DOM', () => {
wrapper.setProps({ numbers: ['0', '1', '2'] })
expect(wrapper.find('.numbers-container').text()).toEqual('012');
});
it('renders the values of operators to the DOM', () => {
wrapper.setProps({ operators: ['+', '-', '*', '/'] });
expect(wrapper.find('.operators-container').text()).toEqual('+-*/');
});
});`
测试应该会通过。运行应用程序。您应该看到:
添加键盘快照
现在已经完成了Keypad
组件的 UI,将快照测试添加到 Keypad.spec.js :
`it('should render correctly', () => expect(wrapper).toMatchSnapshot());`
同样,快照测试将立即通过。
重构计算器状态
将数字和运算符值添加到 Calculator.jsx 中的状态对象:
`... class Calculator extends Component { state = { // value to be displayed in <Display /> displayValue: '0', // values to be displayed in number <Keys /> numbers: ['9', '8', '7', '6', '5', '4', '3', '2', '1', '.', '0','ce'], // values to be displayed in operator <Keys /> operators: ['/', 'x', '-', '+'], // operator selected for math operation selectedOperator: '', // stored value to use for math operation storedValue: '', } ... } ...`
更改之后,Calculator
快照中断,因为我们对Calculator
的 UI 进行了更改。我们需要更新快照。这可以通过在任务运行程序中输入u
或者在从命令行调用测试运行程序时传递--updateSnapshot
标志来实现:
`$ npm test --updateSnapshot`
运行应用程序:
我们已经完成了 UI 的开发和组件渲染测试的编写。现在我们已经准备好给我们的计算器添加功能了。
应用功能
在本节中,我们将使用 TDD 来编写我们的应用程序函数,updateDisplay
、setOperator
和callOperator
,利用红色 - 绿色 - 重构循环来创建失败的测试,然后编写相应的代码来使它们通过。我们将从测试不同计算器方法的click
事件开始。
单击事件测试
对于每个 calculator 方法,我们将编写测试来检查当相应的键类型被单击时对各个方法的调用。
这些测试将进入它们自己的describe
块,因为我们需要使用mount
而不是shallow
,因为我们正在测试子组件的行为。测试包括:
- 使用 Jest
spyOn
方法为我们正在测试的计算器方法创建一个spy
- 调用
forceUpdate
在测试中重新渲染instance
- 使用 Enzyme 的
simulate
方法对相应的Key
创建事件
在 Calculator.spec.js 中添加以下内容:
`describe('mounted Calculator', () => { let wrapper; beforeEach(() => wrapper = mount(<Calculator />)); it('calls updateDisplay when a number key is clicked', () => { const spy = jest.spyOn(wrapper.instance(), 'updateDisplay'); wrapper.instance().forceUpdate(); expect(spy).toHaveBeenCalledTimes(0); wrapper.find('.number-key').first().simulate('click'); expect(spy).toHaveBeenCalledTimes(1); }); it('calls setOperator when an operator key is clicked', () => { const spy = jest.spyOn(wrapper.instance(), 'setOperator'); wrapper.instance().forceUpdate(); expect(spy).toHaveBeenCalledTimes(0); wrapper.find('.operator-key').first().simulate('click'); expect(spy).toHaveBeenCalledTimes(1); }); it('calls callOperator when the submit key is clicked', () => { const spy = jest.spyOn(wrapper.instance(), 'callOperator'); wrapper.instance().forceUpdate(); expect(spy).toHaveBeenCalledTimes(0); wrapper.find('.submit-key').simulate('click'); expect(spy).toHaveBeenCalledTimes(1); }); });`
不要忘记导入mount
:
`import { mount, shallow } from 'enzyme';`
现在重构 Key.jsx 来对click
事件执行计算器方法:
`...
const Key = ({ keyAction, keyType, keyValue }) => (
<div
className={`key-container ${keyType}`}
onClick={() => keyAction(keyValue)}
>
<p className="key-value">
{keyValue}
</p>
</div>
);
...`
测试将通过,但是Key
快照失败。通过在测试运行程序中输入u
或从命令行 run 更新Key
快照:
`$ npm test --updateSnapshot`
现在onClick
处理程序已经添加到了Key
中,运行应用程序,然后跳回浏览器并打开 JavaScript 控制台。点击一个数字键。click
事件的输出应该是这样的:
现在我们已经为功能测试做好了准备!
更新显示测试
updateDisplay
方法将接受一个字符串参数value
,并更新state
对象中的displayValue
。当displayValue
被更新时,React 将重新渲染Display
组件,并将displayValue
的新值作为显示文本。
我们需要在我们的Calculator
测试文件中为updateDisplay
添加一个新的describe
块,然后为updateDisplay
方法添加我们的测试。在测试中,将从wrapper.instance()
对象中调用updateDisplay
,并根据state
对象测试结果。
导航到 Calculator.spec.js ,声明describe
块,并在其中添加测试:
`describe('updateDisplay', () => { let wrapper; beforeEach(() => wrapper = shallow(<Calculator />)); it('updates displayValue', () => { wrapper.instance().updateDisplay('5'); expect(wrapper.state('displayValue')).toEqual('5'); }); it('concatenates displayValue', () => { wrapper.instance().updateDisplay('5'); wrapper.instance().updateDisplay('0'); expect(wrapper.state('displayValue')).toEqual('50'); }); it('removes leading "0" from displayValue', () => { wrapper.instance().updateDisplay('0'); expect(wrapper.state('displayValue')).toEqual('0'); wrapper.instance().updateDisplay('5'); expect(wrapper.state('displayValue')).toEqual('5'); }); it('prevents multiple leading "0"s from displayValue', () => { wrapper.instance().updateDisplay('0'); wrapper.instance().updateDisplay('0'); expect(wrapper.state('displayValue')).toEqual('0'); }); it('removes last char of displayValue', () => { wrapper.instance().updateDisplay('5'); wrapper.instance().updateDisplay('0'); wrapper.instance().updateDisplay('ce'); expect(wrapper.state('displayValue')).toEqual('5'); }); it('prevents multiple instances of "." in displayValue', () => { wrapper.instance().updateDisplay('.'); wrapper.instance().updateDisplay('.'); expect(wrapper.state('displayValue')).toEqual('.'); }); it('will set displayValue to "0" if displayValue is equal to an empty string', () => { wrapper.instance().updateDisplay('ce'); expect(wrapper.state('displayValue')).toEqual('0'); }); });`
现在,导航到 Calculator.jsx 并更新updateDisplay
:
`... class Calculator extends Component { ... updateDisplay = value => { let { displayValue } = this.state; // prevent multiple occurences of '.' if (value === '.' && displayValue.includes('.')) value = ''; if (value === 'ce') { // deletes last char in displayValue displayValue = displayValue.substr(0, displayValue.length - 1); // set displayValue to '0' if displayValue is empty string if (displayValue === '') displayValue = '0'; } else { // replace displayValue with value if displayValue equal to '0' // else concatenate displayValue and value displayValue === '0' ? displayValue = value : displayValue += value; } this.setState({ displayValue }); } ... } ...`
您必须小心 React 基于类的组件中声明方法时使用的语法。当使用 es5 对象方法语法时,默认情况下方法不绑定到类,绑定必须在
constructor
方法中显式声明。例如,如果您忘记绑定this.handleClick
并将其传递给一个onClick
处理程序,当函数被实际调用时,this
将成为undefined
。在本文中,我们使用 es6 中引入的胖箭头方法语法,它为我们处理方法绑定,并允许我们在初始化组件状态时省略constructor
方法。es5 示例:
class Calculator extends Component { constructor(props) { this.state = { displayValue: '0', } // explicit binding this.updateDisplay = this.updateDisplay.bind(this); } updateDisplay(value) { this.setState({ displayValue: value }); } }
es6 或更高版本示例:
class Calculator extends Component { state = { displayValue: '0', } updateDisplay = value => this.setState({ displayValue: value }); }
有关绑定的更多信息,请参考 React 文档。
所有测试现在都应该通过了,导航到浏览器并单击数字键以查看显示更新。
现在转到setOperator
方法!
设置操作员测试
setOperator
方法将接受一个字符串参数value
,它将更新state
对象中的displayValue
、selectedOperator
和storedValue
。
同样,在我们的Calculator
测试文件中为setOperator
添加一个describe
块,然后为setOperator
方法添加测试。像以前一样,setOperator
将从wrapper.instance()
对象中被调用,结果将对照state
对象进行测试。
导航到 Calculator.spec.js ,添加describe
块以及测试:
`describe('setOperator', () => { let wrapper; beforeEach(() => wrapper = shallow(<Calculator />)); it('updates the value of selectedOperator', () => { wrapper.instance().setOperator('+'); expect(wrapper.state('selectedOperator')).toEqual('+'); wrapper.instance().setOperator('/'); expect(wrapper.state('selectedOperator')).toEqual('/'); }); it('updates the value of storedValue to the value of displayValue', () => { wrapper.setState({ displayValue: '5' }); wrapper.instance().setOperator('+'); expect(wrapper.state('storedValue')).toEqual('5'); }); it('updates the value of displayValue to "0"', () => { wrapper.setState({ displayValue: '5' }); wrapper.instance().setOperator('+'); expect(wrapper.state('displayValue')).toEqual('0'); }); it('selectedOperator is not an empty string, does not update storedValue', () => { wrapper.setState({ displayValue: '5' }); wrapper.instance().setOperator('+'); expect(wrapper.state('storedValue')).toEqual('5'); wrapper.instance().setOperator('-'); expect(wrapper.state('storedValue')).toEqual('5'); }); });`
导航到 Calculator.jsx 。更新setOperator
方法:
`... class Calculator extends Component { ... setOperator = value => { let { displayValue, selectedOperator, storedValue } = this.state; // check if a value is already present for selectedOperator if (selectedOperator === '') { // update storedValue to the value of displayValue storedValue = displayValue; // reset the value of displayValue to '0' displayValue = '0'; // update the value of selectedOperator to the given value selectedOperator = value; } else { // if selectedOperator is not an empty string // update the value of selectedOperator to the given value selectedOperator = value; } this.setState({ displayValue, selectedOperator, storedValue }); } ... } export default Calculator;`
同样,所有的测试现在都是绿色的。继续前进到callOperator
。
呼叫接线员测试
callOperator
方法没有参数。它更新state
对象中的displayValue
、selectedOperator
和storedValue
。
同样,在我们的Calculator
测试文件中,我们需要一个callOperator
的describe
块。然后,我们将在里面添加我们对callOperator
方法的测试。和上面的部分一样,callOperator
将从wrapper.instance()
对象中被调用,结果将根据state
对象进行测试。
导航到 Calculator.spec.js ,并在文件底部添加新的describe
块:
`describe('callOperator', () => { let wrapper; beforeEach(() => wrapper = shallow(<Calculator />)); it('updates displayValue to the sum of storedValue and displayValue', () => { wrapper.setState({ storedValue: '3' }); wrapper.setState({ displayValue: '2' }); wrapper.setState({ selectedOperator: '+' }); wrapper.instance().callOperator(); expect(wrapper.state('displayValue')).toEqual('5'); }); it('updates displayValue to the difference of storedValue and displayValue', () => { wrapper.setState({ storedValue: '3' }); wrapper.setState({ displayValue: '2' }); wrapper.setState({ selectedOperator: '-' }); wrapper.instance().callOperator(); expect(wrapper.state('displayValue')).toEqual('1'); }); it('updates displayValue to the product of storedValue and displayValue', () => { wrapper.setState({ storedValue: '3' }); wrapper.setState({ displayValue: '2' }); wrapper.setState({ selectedOperator: 'x' }); wrapper.instance().callOperator(); expect(wrapper.state('displayValue')).toEqual('6'); }); it('updates displayValue to the quotient of storedValue and displayValue', () => { wrapper.setState({ storedValue: '3' }); wrapper.setState({ displayValue: '2' }); wrapper.setState({ selectedOperator: '/' }); wrapper.instance().callOperator(); expect(wrapper.state('displayValue')).toEqual('1.5'); }); it('updates displayValue to "0" if operation results in "NaN"', () => { wrapper.setState({ storedValue: '3' }); wrapper.setState({ displayValue: 'string' }); wrapper.setState({ selectedOperator: '/' }); wrapper.instance().callOperator(); expect(wrapper.state('displayValue')).toEqual('0'); }); it('updates displayValue to "0" if operation results in "Infinity"', () => { wrapper.setState({ storedValue: '7' }); wrapper.setState({ displayValue: '0' }); wrapper.setState({ selectedOperator: '/' }); wrapper.instance().callOperator(); expect(wrapper.state('displayValue')).toEqual('0'); }); it('updates displayValue to "0" if selectedOperator does not match cases', () => { wrapper.setState({ storedValue: '7' }); wrapper.setState({ displayValue: '10' }); wrapper.setState({ selectedOperator: 'string' }); wrapper.instance().callOperator(); expect(wrapper.state('displayValue')).toEqual('0'); }); it('updates displayValue to "0" if called with no value for storedValue or selectedOperator', () => { wrapper.setState({ storedValue: '' }); wrapper.setState({ displayValue: '10' }); wrapper.setState({ selectedOperator: '' }); wrapper.instance().callOperator(); expect(wrapper.state('displayValue')).toEqual('0'); }); });`
导航到 Calculator.jsx ,然后更新callOperator
方法:
`class Calculator extends Component { ... callOperator = () => { let { displayValue, selectedOperator, storedValue } = this.state; // temp variable for updating state storedValue const updateStoredValue = displayValue; // parse strings for operations displayValue = parseInt(displayValue, 10); storedValue = parseInt(storedValue, 10); // performs selected operation switch (selectedOperator) { case '+': displayValue = storedValue + displayValue; break; case '-': displayValue = storedValue - displayValue; break; case 'x': displayValue = storedValue * displayValue; break; case '/': displayValue = storedValue / displayValue; break; default: // set displayValue to zero if no case matches displayValue = '0'; } // converts displayValue to a string displayValue = displayValue.toString(); // reset selectedOperator selectedOperator = ''; // check for 'NaN' or 'Infinity', if true set displayValue to '0' if (displayValue === 'NaN' || displayValue === 'Infinity') displayValue = '0'; this.setState({ displayValue, selectedOperator, storedValue: updateStoredValue }); } ... } export default Calculator;`
计算器现在功能齐全了!
所有测试都应该通过!
`PASS src/components/App/App.spec.js
PASS src/components/Keypad/Keypad.spec.js
PASS src/components/Key/Key.spec.js
PASS src/components/Calculator/Calculator.spec.js
PASS src/components/Display/Display.spec.js
Test Suites: 5 passed, 5 total
Tests: 39 passed, 39 total
Snapshots: 5 passed, 5 total
Time: 2.603s
Ran all test suites.`
最后的想法
此时,我们有:
- 使用测试驱动的开发,以及 Enzyme 和 Jest 来构建我们的应用程序和编写我们的测试。
- 使用 CSS 变量,允许变量重用和重新分配,以响应设计。
- 编写了一个可重用的 React 组件,我们可以用单独的函数和多种风格来呈现它。
- 在整个应用程序中使用 React 的 PropTypes 进行类型检查。
后续步骤:
如果你玩计算器,你可能会注意到一个奇怪的现象,即.
键并不像预期的那样工作。你知道该怎么做:先写一个测试,调试,然后写代码通过测试。
你可能遇到的另一个怪癖是,如果你在一个操作之后点击一个键(不管是哪个键),如果我们试图模仿使用一个普通计算器的体验,displayValue
不会像我们预期的那样更新。将这个计算器与另一个计算器进行比较,隔离体验中的差异,为新的结果编写一些测试,并更新计算器的功能以使测试变为绿色。
尝试使用 CSS:
完成以上步骤后,下一步可能是为应用程序添加一个加载转换或键盘事件的事件监听器,以获得更好的用户体验。如果你对如何设置后者感到好奇,你可以在 GitHub 上的 react-calculator repo 的master
分支中找到完整的应用程序。
希望你喜欢这篇文章!
用 Cypress 和 Docker 测试角度
原文:https://testdriven.io/blog/testing-angular-with-cypress-and-docker/
Cypress 是一个强大的测试框架,它使得编写端到端的测试变得很快,只需要很少的设置。
这篇文章详细介绍了如何使用 Cypress 和 Docker 为 Angular 应用程序添加端到端(E2E)测试。我们将着眼于向一个新的和现有的 Angular 项目添加测试,以及将 Cypress 合并到您与 Docker 的持续集成流程中。
依赖关系:
- cypress 2 . 1 . 0 版
- Angular CLI 版本 1.7.3 (Angular 版本 5.2.0)
- 坞站 v18.03.1-ce
- 节点 v10.0.0
目标
本教程结束时,您将能够...
- 将 Cypress 添加到新的和现有的角度项目中
- 使用端到端测试测试 Angular 应用程序
- 设置 Cypress 从 Docker 容器运行
- 将 Cypress 集成到一个持续的集成过程中
新角度项目
这一部分着眼于如何将 Cypress 添加到新的 Angular 项目中。
如果您还没有安装 Angular CLI ,则从全局安装开始,然后创建一个新的 Angular 项目:
`$ npm install -g @angular/[[email protected]](/cdn-cgi/l/email-protection)
$ ng new angular-cypress
$ cd angular-cypress`
运行服务器:
在浏览器中导航至 http://localhost:4200 。您应该看到“欢迎使用应用程序!”带棱角标志的信息。完成后杀死服务器。
然后,安装 Cypress:
打开 Cypress 测试运行器:
`$ ./node_modules/.bin/cypress open`
由于这是我们第一次运行测试运行程序,Cypress 将自动搭建一个文件夹结构:
`└─── cypress
├── fixtures
│ └── example.json
├── integration
│ └── example_spec.js
├── plugins
│ └── index.js
└── support
├── commands.js
└── index.js`
它还将向项目根添加一个空的 cypress.json 配置文件。
值得注意的是,此时我们已经可以开始编写和运行测试了。Cypress 附带了启动和运行所需的一切,无需任何配置!
最后,移除示例规范文件-cypress/integration/example _ spec . js。在它的位置,添加一个 spec.js 文件:
`describe('My App', () => { it('loads', () => { cy.visit('/'); cy.get('h1').contains('Welcome to app!'); }); });`
Cypress 对 TypeScript 有支持,但是我遇到了许多类型定义冲突。截至发稿时,GitHub 上有一个公开问题来解决这个问题。
这是一个基本的测试规范文件,它打开主页面并确认页面加载,并且有一个带有Welcome to app!
文本的H1
标签。
命令:
visit()
访问提供的网址。get()
通过选择器查询元素。contains()
获取包含文本的元素。这也是一个内置的断言。
关于这些命令的更多信息,请查看 Cypress API 。
赛普拉斯在引擎盖下用一把摩卡的叉子作为它的测试者,还有柴作为断言,西农作为嘲讽和揶揄。因此,如果你过去用过摩卡或茉莉,你应该会觉得describe
和it
块很熟悉。
通过更新项目根目录中的 cypress.json 文件,设置baseUrl
并关闭视频录制:
`{ "baseUrl": "http://127.0.0.1:4200", "videoRecording": "false" }`
然后 Cypress 会在cy.visit()
中的 URL 前面加上baseUrl
。
在一个终端窗口运行 Angular 应用程序:
然后,在另一个终端窗口中打开测试运行程序:
`$ ./node_modules/.bin/cypress open`
测试在一个电子应用程序中运行,该应用程序将你的测试与被测应用程序并排显示。单击“运行所有测试”按钮开始新的测试运行:
Cypress 将推出一个新的用户界面,它将完成规格文件中的每一步:
在运行器激活的情况下,Cypress 会监视您的代码,并且会在发生更改时重新运行测试。试试吧!对src/app/app . component . html模板中的<h1></h1>
元素进行快速更改,以观察测试的中断。
恢复您刚才所做的更改,以便再次通过测试,然后在继续之前停止测试运行程序和 Angular development server。
CI 的 Docker
让我们将 Docker 添加到组合中,以简化持续集成环境的测试——如 Jenkins 、 Travis CI 或 Circle 。在这些环境中,我们希望在一个容器中提供 Angular 应用程序,在另一个容器中运行 Cypress 测试。
添加一个 Dockerfile :
`#########################
### build environment ###
#########################
# base image
FROM node:9.6.1 as builder
# install chrome for protractor tests
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
RUN apt-get update && apt-get install -yq google-chrome-stable
# set working directory
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
# add `/usr/src/app/node_modules/.bin` to $PATH
ENV PATH /usr/src/app/node_modules/.bin:$PATH
# install and cache app dependencies
COPY package.json /usr/src/app/package.json
RUN npm install
RUN npm install -g @angular/[[email protected]](/cdn-cgi/l/email-protection) --unsafe
# add app
COPY . /usr/src/app
# run tests
RUN ng test --watch=false
# generate build
RUN npm run build
##################
### production ###
##################
# base image
FROM nginx:1.13.9-alpine
# copy artifact build from the 'build environment'
COPY --from=builder /usr/src/app/dist /usr/share/nginx/html
# expose port 80
EXPOSE 80
# run nginx
CMD ["nginx", "-g", "daemon off;"]`
在这里,使用 Docker 多级构建,我们创建了一个用于构建 Angular 应用程序的临时映像。角度构建过程完成后,静态文件被复制到生产映像,临时映像被丢弃。
有关这方面的更多信息,请查看dockering an Angular App的博文。
下一步,添加一个坞站-复合. yml 文件:
`version: '3.6' services: angular: build: context: . dockerfile: Dockerfile ports: - '80:80'`
构建映像并旋转容器:
`$ docker-compose up -d --build`
一旦启动,确保应用程序在 http://localhost 上运行。
同样,我们将在一个单独的容器中运行 Cypress 测试,所以添加一个名为 Dockerfile-cypress 的新 Dockerfile:
`# base image
FROM cypress/browsers:chrome65-ff57 # set working directory
RUN mkdir /usr/src/app WORKDIR /usr/src/app # install cypress
RUN npm install cypress@2.1.0 # copy cypress files and folders
COPY cypress /usr/src/app/cypress COPY cypress.json /usr/src/app/cypress.json # confirm the cypress install
RUN ./node_modules/.bin/cypress verify`
查看官方 Cypress Docker Images repo,了解更多关于可用基本图像的信息。
像这样更新 docker-compose.yml 文件:
`version: '3.6' services: angular: build: context: . dockerfile: Dockerfile ports: - '80:80' cypress: build: context: . dockerfile: Dockerfile-cypress depends_on: - angular network_mode: 'host'`
注意网络模式。通过将其设置为host
,Cypress 容器将能够从主机访问 localhost。查看这个堆栈溢出问题了解更多信息。
现在,当我们运行测试时,我们需要覆盖 cypress.json 文件中设置的baseURL
:
`$ docker-compose run cypress ./node_modules/.bin/cypress run \
--config baseUrl=http://127.0.0.1`
值得一提的是,您还可以将一个不同的配置文件复制到容器中,该配置文件是特定于使用 Docker 和/或在 CI 环境中运行测试的,如下所示:
`COPY cypress-ci.json /usr/src/app/cypress.json`
测试应该通过:
`(Tests Starting)
My App
✓ loads (679ms)
1 passing (792ms)
(Tests Finished)
- Tests: 1
- Passes: 1
- Failures: 0
- Pending: 0
- Duration: 0 seconds
- Screenshots: 0
- Video Recorded: false
- Cypress Version: 2.1.0
(All Done)`
把容器拿下来:
特拉维斯·CI
接下来,让我们使用 Travis CI 进行持续集成。向项目根目录添加一个 .travis.yml 文件:
`language: node_js node_js: - 10 services: - docker env: global: - DOCKER_COMPOSE_VERSION=1.21.1 before_install: - sudo rm /usr/local/bin/docker-compose - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose - chmod +x docker-compose - sudo mv docker-compose /usr/local/bin before_script: - sleep 3 - docker-compose up -d --build script: - docker-compose run cypress ./node_modules/.bin/cypress run --config baseUrl=http://127.0.0.1 after_script: - docker-compose down`
如果你想测试一下,在 GitHub 上创建一个新的 repo,为这个 repo 启用 Travis,然后推上你的代码。
至此,让我们继续向现有的 Angular 项目添加 Cypress 测试。
现有角度项目
在这一节中,我们将把 Cypress 添加到 Angular 项目中,该项目已经构建了基于令牌的认证。app 本身来自 Angular 中的认证与 NGRX 博文。代码中有一个小错误,我们将用 Cypress 来充实它。
首先克隆项目并安装依赖项:
`$ git clone https://github.com/mjhea0/angular-auth-ngrx
$ cd angular-auth-ngrx
$ npm install`
该应用程序需要具有以下路由的后端:
统一资源定位器 | HTTP 动词 | 行动 |
---|---|---|
http://localhost:1337/register | 邮政 | 注册新用户 |
http://localhost:1337/login | 邮政 | 让用户登录 |
http://本地主机:1337/status | 得到 | 获取用户状态 |
我们将使用一个生成伪令牌的伪后端来测试前端的功能。在新的终端窗口中克隆 repo,安装依赖项,启动应用程序:
`$ git clone https://github.com/testdrivenio/fake-token-api
$ cd fake-token-api
$ npm install
$ npm start`
旋转角度应用程序:
导航到 http://localhost:4200 。确保您可以注册并使用以下凭据登录:
让开发服务器保持运行。
添加柏树
安装 Cypress 并在新的终端窗口中打开测试运行程序:
移除示例规范文件并更新 cypress.json :
`{ "baseUrl": "http://127.0.0.1:4200", "videoRecording": false }`
让我们编写一些测试来确保:
- 主页面加载
- 用户可以注册
- 用户可以登录
- 只有经过认证的用户才能查看
/status
路线
我们将按组件对其进行分类:
- 登陆
- 注册
- 签约雇用
- 状态
测试:着陆组件
添加一个新的规格文件-landing . component . spec . js:
`describe('Landing Component', () => { it('should display the landing page', () => { cy .visit('/') .get('h1').contains('Angular + NGRX') .get('a.btn').contains('Log in') .get('a.btn').contains('Sign up') .get('a.btn').contains('Status'); }); });`
这里没有什么新东西,所以继续运行测试:
测试:登录组件
再次添加一个新的规范文件-log in . component . spec . js:
`describe('LogIn Component', () => { it('should log a user in', () => { cy .visit('/') .get('a.btn').contains('Log in').click() .get('form input[name="email"]').clear().type('[[email protected]](/cdn-cgi/l/email-protection)') .get('form input[name="password"]').clear().type('test') .get('button[type="submit"]').click() .get('p').contains('You logged in [[email protected]](/cdn-cgi/l/email-protection)!') .get('a.btn').contains('Log in').should('not.be.visible') .get('a.btn').contains('Sign up').should('not.be.visible') .get('a.btn').contains('Status') .get('button.btn').contains('Log out'); }); });`
在这个测试中,我们导航到主页,单击 Log in 按钮,提交带有正确凭证的表单,然后确认登录成功。
运行测试!
尝试将鼠标悬停在 UI 左侧的一个测试步骤上——它将显示该步骤的 DOM 快照。默认情况下,Cypress 会保存 50 张快照。例如,将鼠标悬停在CONTAINS You logged in [[email protected]](/cdn-cgi/l/email-protection)!
步骤上。请注意,该元素现在是如何在右侧的应用程序中突出显示的:
您也可以打开开发人员工具来获取有关该步骤的更多信息:
最后,您可以使用调试器来停止正在运行的测试,检查变量的状态,进行更改,然后重新运行测试。
这些特性使得调试失败的测试变得容易。
接下来,让我们确保如果凭据不正确,用户不会登录。像这样更新规范:
`describe('LogIn Component', () => { it('should log a user in if the credentials are valid', () => { cy .visit('/') .get('a.btn').contains('Log in').click() .get('form input[name="email"]').clear().type('[[email protected]](/cdn-cgi/l/email-protection)') .get('form input[name="password"]').clear().type('test') .get('button[type="submit"]').click(); cy .get('p').contains('You logged in [[email protected]](/cdn-cgi/l/email-protection)!') .get('a.btn').contains('Log in').should('not.be.visible') .get('a.btn').contains('Sign up').should('not.be.visible') .get('a.btn').contains('Status') .get('button.btn').contains('Log out'); }); it('should not log a user in if the credentials are invalid', () => { cy .visit('/') .get('a.btn').contains('Log in').click() .get('form input[name="email"]').clear().type('[[email protected]](/cdn-cgi/l/email-protection)') .get('form input[name="password"]').clear().type('incorrect') .get('button[type="submit"]').click(); cy .get('p') .contains('You logged in [[email protected]](/cdn-cgi/l/email-protection)!') .should('not.be.visible'); cy .get('div.alert.alert-danger') .contains('Incorrect email and/or password.'); }); });`
测试应该会通过。为了保持代码简洁,我们可以创建自己的定制命令让用户登录。简而言之,Cypress 命令允许您创建自己的可重用动作。
更新cypress/support/commands . js:
`Cypress.Commands.add('login', (email, password) => { cy .visit('/') .get('a.btn').contains('Log in').click() .get('form input[name="email"]').clear().type(email) .get('form input[name="password"]').clear().type(password) .get('button[type="submit"]').click(); });`
然后更新规格:
`describe('LogIn Component', () => { it('should log a user in if the credentials are valid', () => { cy .login('[[email protected]](/cdn-cgi/l/email-protection)', 'test'); cy .location('pathname').should('eq', '/') .get('p').contains('You logged in [[email protected]](/cdn-cgi/l/email-protection)!') .get('a.btn').contains('Log in').should('not.be.visible') .get('a.btn').contains('Sign up').should('not.be.visible') .get('a.btn').contains('Status') .get('button.btn').contains('Log out'); }); it('should not log a user in if the credentials are invalid', () => { cy .login('[[email protected]](/cdn-cgi/l/email-protection)', 'incorrect'); cy .get('p') .contains('You logged in [[email protected]](/cdn-cgi/l/email-protection)!') .should('not.be.visible'); cy .location('pathname').should('eq', '/log-in') .get('div.alert.alert-danger') .contains('Incorrect email and/or password.'); }); });`
确保测试仍然通过。您是否注意到我们添加了一个带有location()
的附加断言,以确保用户在成功登录后被正确重定向?
让我们再添加一个退出测试:
`it('should log an authenticated user out', () => { cy .login('[[email protected]](/cdn-cgi/l/email-protection)', 'test'); cy .get('p').contains('You logged in [[email protected]](/cdn-cgi/l/email-protection)!') .get('button.btn').contains('Log out').click() cy .location('pathname').should('eq', '/') .get('h1').contains('Angular + NGRX') .get('a.btn').contains('Log in') .get('a.btn').contains('Sign up') .get('a.btn').contains('Status'); });`
测试:注册组件
测试这个组件与登录组件非常相似。首先创建一个新的规范文件-sign up . component . spec . js。
测试:
`describe('SignUp Component', () => { it('should sign a user up', () => { cy .signup('[[email protected]](/cdn-cgi/l/email-protection)', 'test'); cy .location('pathname').should('eq', '/') .get('p').contains('You logged in [[email protected]](/cdn-cgi/l/email-protection)!') .get('a.btn').contains('Log in').should('not.be.visible') .get('a.btn').contains('Sign up').should('not.be.visible') .get('a.btn').contains('Status') .get('button.btn').contains('Log out'); }); it('should throw an error if the email is already is use', () => { cy .signup('[[email protected]](/cdn-cgi/l/email-protection)', 'test'); cy .get('p') .contains('You logged in [[email protected]](/cdn-cgi/l/email-protection)!') .should('not.be.visible'); cy .location('pathname').should('eq', '/sign-up') .get('div.alert.alert-danger') .contains('That email is already in use.'); }); });`
添加命令:
`Cypress.Commands.add('login', (email, password) => { cy .visit('/') .get('a.btn').contains('Log in').click() .get('form input[name="email"]').clear().type(email) .get('form input[name="password"]').clear().type(password) .get('button[type="submit"]').click(); }); Cypress.Commands.add('signup', (email, password) => { cy .visit('/') .get('a.btn').contains('Sign up').click() .get('form input[name="email"]').clear().type(email) .get('form input[name="password"]').clear().type(password) .get('button[type="submit"]').click(); });`
确保测试通过:
在继续之前的最后一件事,让我们确保注册表单的状态在初始加载时是正确的。
添加以下测试:
`it('should not display an error message when a user first hits the component', () => { cy .login('[[email protected]](/cdn-cgi/l/email-protection)', 'incorrect') .get('div.alert.alert-danger') .contains('Incorrect email and/or password.') .get('a.btn').contains('Cancel').click() .get('a.btn').contains('Sign up').click(); cy .get('div.alert.alert-danger') .should('not.be.visible'); });`
该测试应该会失败:
向登录规范添加一个类似的测试:
`it('should not display an error message when a user first hits the component', () => { cy .signup('[[email protected]](/cdn-cgi/l/email-protection)', 'test') .get('div.alert.alert-danger') .contains('That email is already in use.') .get('a.btn').contains('Cancel').click() .get('a.btn').contains('Log in').click(); cy .get('div.alert.alert-danger') .should('not.be.visible'); });`
它也应该失败。因此,我们在代码中发现了一个错误:消息状态没有在组件 init 上被清除。您可以自己解决这个问题,或者注释掉失败的断言。
测试:状态组件
添加 spec-status . component . spec . js:
`describe('Status Component', () => { it('should display the component if a user is logged in', () => { cy .login('[[email protected]](/cdn-cgi/l/email-protection)', 'test'); cy .location('pathname').should('eq', '/') .get('p').contains('You logged in [[email protected]](/cdn-cgi/l/email-protection)!') .get('a.btn').contains('Status').click(); cy .location('pathname').should('eq', '/status') .get('h1').contains('Status Works!') .get('a.btn').contains('Home'); }); it('should not display the component if a user is not logged in', () => { cy .visit('/') .get('a.btn').contains('Status').click(); cy .location('pathname').should('eq', '/log-in') .get('h1').contains('Status Works!').should('not.be.visible'); }); });`
确保所有测试都通过:
结论
就是这样!
Cypress 是一个强大的端到端测试工具。有了它,您可以在下载后几分钟内使用熟悉的断言库开始编写测试。测试在电子应用中实时运行,这使得调试失败的断言变得容易。这个项目背后有一个强大的社区,并且文档非常优秀!
从以下回复中获取最终代码:
用 Python 测试
自动化测试一直是软件开发中的热门话题,但在持续集成和微服务时代,它被谈论得更多。有许多工具可以帮助您在 Python 项目中编写、运行和评估测试。让我们来看看其中的几个。
本文是完整 Python 指南的一部分:
pytest
虽然 Python 标准库附带了一个名为“nittest”的单元测试框架,但是 pytest 是测试 Python 代码的首选测试框架。
pytest 让它变得简单(而且有趣!)来编写、组织和运行测试。与 Python 标准库中的 unittest 相比,pytest:
- 需要更少的样板代码,因此您的测试套件将更具可读性。
- 支持简单的
assert
语句,与 unittest 中的assertSomething
方法——如assertEquals
、assertTrue
和assertContains
——相比,它可读性更好,也更容易记住。 - 更新更频繁,因为它不是 Python 标准库的一部分。
- 通过夹具系统简化测试状态的设置和拆除。
- 使用功能方法。
另外,使用 pytest,您可以在所有 Python 项目中保持一致的风格。假设您的堆栈中有两个 web 应用程序——一个用 Django 构建,另一个用 Flask 构建。如果没有 pytest,您很可能会利用 Django 测试框架以及 Flask 扩展,如 Flask-Testing。所以,你的测试套件会有不同的风格。另一方面,使用 pytest,两个测试套件将具有一致的风格,使得从一个跳到另一个更加容易。
pytest 也有一个大型的、由社区维护的插件生态系统。
一些例子:
- pytest-django -提供了一套专门用于测试 django 应用程序的工具
- pytest-xdist -用于并行运行测试
- 添加代码覆盖支持
- pytest-instafail -立即显示故障和错误,而不是等到运行结束
要查看完整的插件列表,请查看文档中的插件列表。
嘲弄的
自动化测试应该是快速的、隔离的/独立的、确定的/可重复的。因此,如果您需要测试向第三方 API 发出外部 HTTP 请求的代码,您应该真正模拟该请求。为什么?如果你不知道,那么具体的测试将会是-
- 速度慢,因为它通过网络发出 HTTP 请求
- 取决于第三方服务和网络本身的速度
- 不确定性,因为根据 API 的响应,测试可能会产生不同的结果
模仿其他长时间运行的操作也是一个好主意,比如数据库查询和异步任务,因为自动化测试通常会在每次提交到源代码控制时频繁运行。
模仿是指在运行时用模仿对象替换真实对象的行为。因此,当被模仿的方法被调用时,我们只是返回一个预期的响应,而不是通过网络发送一个真正的 HTTP 请求。
例如:
`import requests
def get_my_ip():
response = requests.get(
'http://ipinfo.io/json'
)
return response.json()['ip']
def test_get_my_ip(monkeypatch):
my_ip = '123.123.123.123'
class MockResponse:
def __init__(self, json_body):
self.json_body = json_body
def json(self):
return self.json_body
monkeypatch.setattr(
requests,
'get',
lambda *args, **kwargs: MockResponse({'ip': my_ip})
)
assert get_my_ip() == my_ip`
这里发生了什么事?
我们使用 pytest 的 monkeypatch fixture 将所有从requests
模块对get
方法的调用替换为总是返回MockedResponse
实例的lambda
回调。
我们使用一个对象,因为
requests
返回一个响应对象。
我们可以用来自unittest.mock
模块的 create_autospec 方法来简化测试。该方法创建一个模拟对象,该对象具有与作为参数传递的对象相同的属性和方法:
`from unittest import mock
import requests
from requests import Response
def get_my_ip():
response = requests.get(
'http://ipinfo.io/json'
)
return response.json()['ip']
def test_get_my_ip(monkeypatch):
my_ip = '123.123.123.123'
response = mock.create_autospec(Response)
response.json.return_value = {'ip': my_ip}
monkeypatch.setattr(
requests,
'get',
lambda *args, **kwargs: response
)
assert get_my_ip() == my_ip`
尽管 pytest 推荐使用 monkeypatch 方法进行模仿,但是标准库中的 pytest-mock 扩展和普通的 unittest.mock 库也是不错的方法。
代码覆盖率
测试的另一个重要方面是代码覆盖率。这是一个指标,它告诉你在测试运行期间执行的行数与你的代码库中所有行的总数之间的比率。为此,我们可以使用 pytest-cov 插件,它集成了 pytest 和 T2 的覆盖率。
安装后,要运行覆盖报告测试,添加--cov
选项,如下所示:
`$ python -m pytest --cov=.`
它将产生如下输出:
`================================== test session starts ==================================
platform linux -- Python 3.7.9, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: /home/johndoe/sample-project
plugins: cov-2.10.1
collected 6 items
tests/test_sample_project.py .... [ 66%]
tests/test_sample_project_mock.py . [ 83%]
tests/test_sample_project_mock_1.py . [100%]
----------- coverage: platform linux, python 3.7.9-final-0 -----------
Name Stmts Miss Cover
---------------------------------------------------------
sample_project/__init__.py 1 1 0%
tests/__init__.py 0 0 100%
tests/test_sample_project.py 5 0 100%
tests/test_sample_project_mock.py 13 0 100%
tests/test_sample_project_mock_1.py 12 0 100%
---------------------------------------------------------
TOTAL 31 1 97%
================================== 6 passed in 0.13s ==================================`
对于项目路径中的每个文件,您将获得:
- Stmts -代码行数
- miss——测试没有执行的行数
- Cover -文件的覆盖率
在底部,有一行是整个项目的总数。
请记住,尽管鼓励实现高覆盖率,但这并不意味着你的测试是好的测试,测试你代码的每一条快乐和异常路径。例如,使用像assert sum(3, 2) == 5
这样的断言的测试可以达到很高的覆盖率,但是你的代码实际上仍然没有经过测试,因为异常路径没有被覆盖。
突变测试
变异测试有助于确保您的测试实际上覆盖了代码的全部行为。换句话说,它分析测试套件的有效性或健壮性。在突变测试过程中,一个工具会遍历源代码的每一行,做出一些小的改变(称为突变)来破坏你的代码。在每一次变异之后,该工具都会运行您的单元测试,并检查您的测试是否失败。如果您的测试仍然通过,那么您的代码没有通过变异测试。
例如,假设您有以下代码:
`if x > y:
z = 50
else:
z = 100`
变异工具可能会将运算符从>
改为>=
,如下所示:
`if x >= y:
z = 50
else:
z = 100`
mutmut 是 Python 的一个变异测试库。让我们看看它的运行情况。
假设您有下面的Loan
类:
`# loan.py
from dataclasses import dataclass
from enum import Enum
class LoanStatus(str, Enum):
PENDING = "PENDING"
ACCEPTED = "ACCEPTED"
REJECTED = "REJECTED"
@dataclass
class Loan:
amount: float
status: LoanStatus = LoanStatus.PENDING
def reject(self):
self.status = LoanStatus.REJECTED
def rejected(self):
return self.status == LoanStatus.REJECTED`
现在,假设您想要自动拒绝超过 250,000 的贷款请求:
`# reject_loan.py
def reject_loan(loan):
if loan.amount > 250_000:
loan.reject()
return loan`
然后,您编写了以下测试:
`# test_reject_loan.py
from loan import Loan
from reject_loan import reject_loan
def test_reject_loan():
loan = Loan(amount=100_000)
assert not reject_loan(loan).rejected()`
当你用 mutmut 进行突变测试时,你会看到你有两个存活的突变体:
`$ mutmut run --paths-to-mutate reject_loan.py --tests-dir=.
- Mutation testing starting -
These are the steps:
1. A full test suite run will be made to make sure we
can run the tests successfully and we know how long
it takes (to detect infinite loops for example)
2. Mutants will be generated and checked
Results are stored in .mutmut-cache.
Print found mutants with `mutmut results`.
Legend for output:
🎉 Killed mutants. The goal is for everything to end up in this bucket.
⏰ Timeout. Test suite took 10 times as long as the baseline so were killed.
🤔 Suspicious. Tests took a long time, but not long enough to be fatal.
🙁 Survived. This means your tests needs to be expanded.
🔇 Skipped. Skipped.
1. Running tests without mutations
⠏ Running...Done
2. Checking mutants
⠸ 2/2 🎉 0 ⏰ 0 🤔 0 🙁 2 🔇 0`
```py
你可以通过 ID 查看幸存的变种人:
`$ mutmut show 1
--- reject_loan.py
+++ reject_loan.py
@@ -1,7 +1,7 @@
reject_loan.py
def reject_loan(loan):
- if loan.amount > 250_000:
-
if loan.amount >= 250_000:
loan.reject()return loan`
`$ mutmut show 2
--- reject_loan.py
+++ reject_loan.py
@@ -1,7 +1,7 @@
reject_loan.py
def reject_loan(loan):
- if loan.amount > 250_000:
-
if loan.amount > 250001:
loan.reject()return loan`
改进您的测试:
`from loan import Loan
from reject_loan import reject_loan
def test_reject_loan():
loan = Loan(amount=100_000)
assert not reject_loan(loan).rejected()
loan = Loan(amount=250_001)
assert reject_loan(loan).rejected()
loan = Loan(amount=250_000)
assert not reject_loan(loan).rejected()`
如果你再次进行突变测试,你会发现没有突变存活下来:
`$ mutmut run --paths-to-mutate reject_loan.py --tests-dir=.
- Mutation testing starting -
These are the steps:
- A full test suite run will be made to make sure we
can run the tests successfully and we know how long
it takes (to detect infinite loops for example) - Mutants will be generated and checked
Results are stored in .mutmut-cache.
Print found mutants with mutmut results
.
Legend for output:
🎉 Killed mutants. The goal is for everything to end up in this bucket.
⏰ Timeout. Test suite took 10 times as long as the baseline so were killed.
🤔 Suspicious. Tests took a long time, but not long enough to be fatal.
🙁 Survived. This means your tests needs to be expanded.
🔇 Skipped. Skipped.
-
Running tests without mutations
⠏ Running...Done -
Checking mutants
⠙ 2/2 🎉 2 ⏰ 0 🤔 0 🙁 0 🔇 0`
现在您的测试更加健壮了。在 *reject_loan.py* 中的任何无意的改变都会导致测试失败。
> Python 的变异测试工具不像其他一些工具那样成熟。比如[突变体](https://github.com/mbj/mutant)就是 Ruby 的一个成熟的突变测试工具。要了解更多关于突变测试的知识,请在[推特](https://twitter.com/_m_b_j_)上关注突变作者。
与任何其他方法一样,突变测试也有一个权衡。虽然它提高了测试套件捕捉 bug 的能力,但它是以速度为代价的,因为您必须运行整个测试套件数百次。它也迫使你真正地测试一切。这有助于发现异常路径,但是您将有更多的测试用例需要维护。
## 假设
[假设](https://hypothesis.readthedocs.io/en/latest/)是在 Python 中进行[基于属性的测试](https://hypothesis.works/articles/what-is-property-based-testing/)的库。基于属性的测试不需要为每一个你想要测试的参数编写不同的测试用例,它会生成大量的随机测试数据,这些数据依赖于之前的测试运行。这有助于增加测试套件的健壮性,同时减少测试冗余。简而言之,您的测试代码将会更干净、更简洁,并且总体上更高效,同时仍然覆盖了广泛的测试数据。
例如,假设您必须为以下函数编写测试:
def increment(num: int) -> int: return num + 1
您可以编写以下测试:
`import pytest
@pytest.mark.parametrize(
'number, result',
[
(-2, -1),
(0, 1),
(3, 4),
(101234, 101235),
]
)
def test_increment(number, result):
assert increment(number) == result`
这种方法没有错。您的代码已经过测试,代码覆盖率很高(准确地说是 100%)。也就是说,基于可能的输入范围,您的代码测试得有多好?有相当多的整数可以测试,但是测试中只使用了其中的四个。在某些情况下,这就足够了。在其他情况下,四种情况是不够的——即非确定性机器学习代码。那些非常小或非常大的数字呢?或者假设您的函数接受一个整数列表而不是单个整数——如果这个列表是空的,或者它包含一个元素、数百个元素或数千个元素呢?在某些情况下,我们根本无法提供(更不用说甚至想出)所有可能的情况。这就是基于属性的测试发挥作用的地方。
> 机器学习算法是基于属性的测试的一个很好的用例,因为很难为复杂的数据集产生(和维护)测试实例。
像假说这样的框架提供了生成随机测试数据的方法(假说称之为[策略](https://hypothesis.readthedocs.io/en/latest/data.html?#core-strategies))。假设还存储以前测试运行的结果,并使用它们来创建新的案例。
> 策略是基于输入数据的形状生成伪随机数据的算法。它是伪随机的,因为生成的数据是基于以前测试的数据。
通过假设使用基于属性的测试的相同测试如下所示:
`from hypothesis import given
import hypothesis.strategies as st
@given(st.integers())
def test_add_one(num):
assert increment(num) == num - 1`
`st.integers()`是一种假设策略,它生成用于测试的随机整数,而`@given`装饰器用于参数化测试函数。因此,当调用测试函数时,从策略中生成的整数将被传递到测试中。
`$ python -m pytest test_hypothesis.py --hypothesis-show-statistics
================================== test session starts ===================================
platform darwin -- Python 3.8.5, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/johndoe/sample-project
plugins: hypothesis-5.37.3
collected 1 item
test_hypothesis.py . [100%]
================================= Hypothesis Statistics ==================================
test_hypothesis.py::test_add_one:
-
during generate phase (0.06 seconds):
- Typical runtimes: < 1ms, ~ 50% in data generation
- 100 passing examples, 0 failing examples, 0 invalid examples
-
Stopped because settings.max_examples=100
=================================== 1 passed in 0.08s ====================================`
## 类型检查
测试是代码,它们应该被如此对待。就像您的业务代码一样,您需要维护和重构它们。你甚至可能需要时不时地处理一些 bug。正因为如此,保持你的测试简短、简单、直截了当是一个好习惯。您还应该注意不要过度测试您的代码。
运行时(或动态)类型的检查器,如 [Typeguard](https://typeguard.readthedocs.io/) 和 [pydantic](https://pydantic-docs.helpmanual.io/) ,可以帮助最小化测试的数量。让我们来看一个 pydantic 的例子。
例如,假设我们有一个只有一个属性的`User`,一个电子邮件地址:
`class User:
def __init__(self, email: str):
self.email = email
user = User(email='[email protected]')`
我们希望确保所提供的电子邮件确实是有效的电子邮件地址。因此,为了验证它,我们必须在某个地方添加一些助手代码。除了编写测试,我们还必须花时间为此编写正则表达式。pydantic 可以帮助解决这个问题。我们可以用它来定义我们的`User`模型:
`from pydantic import BaseModel, EmailStr
class User(BaseModel):
email: EmailStr
user = User(email='[email protected]')`
现在,在创建每个新的`User`实例之前,pydantic 将验证 email 参数。当它不是一个有效的电子邮件-即`User(email='something')` -一个[验证错误](https://pydantic-docs.helpmanual.io/usage/models/#error-handling)将被提出。这消除了编写我们自己的验证器的需要。我们也不需要测试它,因为 pydantic [的维护者为我们处理了](https://github.com/samuelcolvin/pydantic/blob/ab671a36708a14017e2ccc62f72c9f7628628737/tests/test_types.py)。
我们可以减少对任何用户提供的数据进行测试的次数。相反,我们只需要测试是否正确处理了`ValidationError`。
让我们看看 Flask 应用程序中的一个简单例子:
`import uuid
from flask import Flask, jsonify
from pydantic import ValidationError, BaseModel, EmailStr, Field
app = Flask(name)
@app.errorhandler(ValidationError)
def handle_validation_exception(error):
response = jsonify(error.errors())
response.status_code = 400
return response
class Blog(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
author: EmailStr
title: str
content: str`
测试:
`import json
def test_create_blog_bad_request(client):
"""
GIVEN request data with invalid values or missing attributes
WHEN endpoint /create-blog/ is called
THEN it should return status 400 and JSON body
"""
response = client.post(
'/create-blog/',
data=json.dumps(
{
'author': 'John Doe',
'title': None,
'content': 'Some extra awesome content'
}
),
content_type='application/json',
)
assert response.status_code == 400
assert response.json is not None`
## 结论
测试常常让人觉得是一项令人畏惧的任务。总有这样的时候,但是希望这篇文章提供了一些工具,您可以使用它们来使测试变得更容易。将您的测试工作集中在减少古怪的测试上。你的测试也应该是快速的,隔离的/独立的,确定的/可重复的。最后,对您的测试套件有信心将帮助您更频繁地部署到生产环境中,更重要的是,有助于您晚上睡觉。
测试愉快!
> [完整 Python](/guides/complete-python/) 指南:
>
> 1. [现代 Python 环境——依赖性和工作空间管理](/blog/python-environments/)
> 2. [Python 中的测试](/blog/testing-python/)(本文!)
> 3. [Python 中的现代测试驱动开发](/blog/modern-tdd/)
> 4. [Python 代码质量](/blog/python-code-quality/)
> 5. [Python 类型检查](/blog/python-type-checking/)
> 6. [记录 Python 代码和项目](/blog/documenting-python/)
> 7. [Python 项目工作流程](/blog/python-project-workflow/)
# 测试教程
> 原文:<https://testdriven.io/blog/topics/testing/>
## 描述
测试有助于确保您的应用程序能够按照最终用户的预期运行。
具有高测试覆盖率的软件项目从来都不是完美的,但是它是软件质量的一个很好的初始指示器。此外,可测试的代码通常是一个好的软件架构的标志,这就是高级开发人员在整个开发生命周期中考虑测试的原因。
测试类型:
1. 单位
2. 集成/功能
3. 端到端
单元测试测试独立于依赖项的单个代码单元的功能。它们是防止代码库中出现错误和不一致的第一道防线。单元测试是测试驱动开发(TDD)过程的基础部分。
单元测试提高了代码的可维护性。
可维护性是指对您的代码进行错误修复或增强,或者对将来某个时候需要更新您的代码的其他开发人员进行修复或增强。
单元测试应该与持续集成(CI)过程相结合,以确保您的单元测试不断地执行,最好是在每次提交到您的存储库时。一套可靠的单元测试对于在最终用户在生产中遇到缺陷之前,在开发过程的早期快速捕捉缺陷是至关重要的。
TestDriven.io 上与测试相关的文章和教程讲述了如何使用 pytest 进行单元和集成测试,使用 Cypress 进行端到端测试。您还将学习测试 Python 应用程序时要遵循的最佳实践和技术。
用 FastAPI 和 GraphQL 搭建一个 CRUD app。
使用 pytest 测试 Python 代码的基础知识。
测试 Vue 应用程序中的 Pinia 数据存储。
* 发帖者 [发帖者![Amal Shaji](https://github.com/OpenDocCN/geekdoc-devops-zh/raw/master/testdrivenio-blog/img/5bd3dc6b1ef68a485ee3a5a5108fe833.png)阿玛尔沙姬](/authors/shaji/)
* 最后更新于2022 年 9 月 6 日
如何通过假设和图式进行基于属性的测试来测试 FastAPI
本文作为单元测试 Vue 组件的指南。
使用 linters、代码格式化程序和安全漏洞扫描器提高 Python 代码的质量。
对 TDD 如何工作感兴趣?本指南将引导您从头到尾使用现代工具和技术完成整个过程。
pytest 测试烧瓶应用指南。
查看测试驱动开发如何提高代码质量的示例。
通过使用 Django 和 Aloe 编写一个示例特性,引导您完成行为驱动开发(BDD)开发周期。
这篇文章着眼于如何建立自动化的性能测试来发现和防止低效的数据库查询。
本文着眼于一些有助于简化 Python 测试的工具和技术。
这篇文章展示了如何用 Selenium Grid 和 Docker Swarm 分发自动化测试。我们还将了解如何在多种浏览器上运行测试,并自动配置和取消配置机器以降低成本。
在下面的教程中,我们将带您了解如何配置 Cypress 来与 CircleCI 并行运行测试。
这篇文章着眼于如何将 Cypress 引入到您的测试驱动开发工作流中。
这篇文章详细介绍了如何使用 Cypress 和 Docker 为 Angular 应用程序添加端到端测试。
# 测试 Vue 中的 Pinia 数据存储
> 原文:<https://testdriven.io/blog/vue-pinia-testing/>
本教程描述了如何在 [Vue](https://vuejs.org) 应用程序中测试 [Pinia](https://pinia.vuejs.org) 数据存储。
* * *
这是测试 Vue 应用的两部分系列的第二部分:
1. [Vue 组件单元测试指南](/blog/vue-unit-testing/)
2. [测试 Vue 中的 Pinia 数据存储](/blog/vue-pinia-testing/)(本文!)
虽然您不必从第一篇文章开始,但建议至少回顾一下,为本文提供一点背景知识。
> 如果你有兴趣了解更多关于 Vue 的知识,可以看看我的课程:[通过构建和部署 CRUD 应用](/courses/learn-vue/)来学习 Vue。
*依赖关系*:
本文中使用的 Vue Weather App 项目的源代码(以及详细的安装说明)可以在 GitLab 上找到: [Vue Weather App](https://gitlab.com/patkennedy79/vue-weather-app) 。
## 目标
完成本文后,您应该能够:
1. 解释 Pinia 数据存储如何管理状态
2. 解释何时在 Vue 应用中使用 Pinia 数据存储
3. 编写用于测试 Pinia 数据存储的单元测试
4. 编写 Vue 组件如何与 Pinia 数据存储交互的测试
5. 使用 Vitest 运行单元测试
## 皮尼亚是什么?
[Pinia](https://pinia.vuejs.org) 是一个状态管理库。术语“状态管理”指的是 Vue 应用程序中通常由多个组件使用的集中式数据。
Pinia 既是一个库,它提供了创建反应式数据存储的工具,也是一组以受控方式访问(读/写)数据的设计模式。此外,Pinia 与 [Vue DevTools](https://devtools.vuejs.org/) 集成在一起,提供了非常好的调试/分析体验。
以下组件图说明了 Pinia 如何适应将在本教程中测试的 Vue 应用程序:
![Vue Weather App - Component Diagram](https://github.com/OpenDocCN/geekdoc-devops-zh/raw/master/testdrivenio-blog/img/1a908b2e4c6d7857205cd422734e32fc.png)
Pinia 数据存储包含 Vue 应用程序的全局数据。`WeatherContent`和`CityList`组件都与 Pinia 数据存储交互,以添加/检索天气数据。
### 何时使用 Pinia
一旦您了解了 Pinia,将所有数据从每个组件转移到一个数据存储中可能会很有诱惑力。但是,我建议将数据添加到由超过 1 个组件使用的数据存储中。
仅在单个组件中使用的数据最好保留在该组件中(以帮助缩小该数据的范围)。
由> 1 个组件共享的任何数据都是添加到数据存储中的良好候选。
当您开始使用 Pinia 时,您会注意到许多使用 props 和定制事件在组件之间传递的数据变得更适合数据存储。
### pinia 诉 Vuex 案
Vuex 是另一个流行的 Vue 项目状态管理库。虽然它在任何版本的 Vue 上都可以很好地工作,但是 Pinia 有一些简化,使得开发体验更简单,生成的代码更容易阅读和理解。
> 根据正在使用的 Vue 版本不同,Vuex 有不同的版本:Vuex 4.x 配合 Vue 3 使用;Vuex 3.x 与 Vue 2 配合使用。相比之下,Pinia 同时支持 Vue 2 和 Vue 3。
最后,Pinia 是 Vue 的[推荐状态管理库](https://pinia.vuejs.org/introduction.html#comparison-with-vuex)。
### 词汇
有几个术语在 Pinia 中有独特的含义,所以在我们深入研究 Pinia 的工作原理之前,我想先看一下每个术语:
* **状态管理** -以可控的方式管理 Vue 应用的全局数据
* **存储** -存储全局数据的地方
* **状态** -商店中的全局数据
* **Getters** -从存储中检索数据的方法
* **动作** -修改商店中数据的方法
## Pinia 入门
在讨论如何测试 Pinia 数据存储之前,我想简单介绍一下我们将要测试的 [Vue 天气应用](https://gitlab.com/patkennedy79/vue-weather-app)。
> 要玩 Vue 天气应用,请在 Netlify 上查看该应用: [Vue 天气应用- Netlify](https://snazzy-taffy-cd99f4.netlify.app)
如果你想在你的本地机器上运行这个应用,你需要克隆这个库并使用 [NPM](https://www.npmjs.com) 安装依赖项:
```py
`$ git clone [[email protected]](/cdn-cgi/l/email-protection):patkennedy79/vue-weather-app.git
$ cd vue-weather-app
$ npm install`
完成后,可以通过启动开发服务器来运行应用程序:
应用程序构建完成后,您会看到类似于以下内容的成功消息:
`vite v2.9.14 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose
ready in 543ms.`
此时,开发服务器将启动并运行。您可以通过在您最喜欢的网络浏览器中导航到 http://localhost:3000 来查看该应用程序。您可以按城市名称搜索以查看某个城市的当前天气:
使用 OpenWeather API 检索所搜索城市的天气数据;这些城市按搜索顺序排列。点击“清除天气数据”按钮,可以清除城市列表。
有关完整的先决条件和安装说明,请查看 GitLab 上的自述文件。
如果你有兴趣了解更多关于 Pinia 的知识,请查看我的 Vue 课程,该课程教授如何在 Vue 应用中使用 Pinia:通过构建和部署 CRUD 应用来学习 Vue。
装置
应使用 NPM 安装 Pinia:
一旦安装完成,您应该会看到pinia
已经作为依赖项添加到 package.json 中:
`"dependencies": { "axios": "^0.27.2", "pinia": "^2.0.20", "vue": "^3.2.37" },`
此外,因为我们将测试用 Pinia 创建的数据存储,所以安装 Pinia 测试包:
`$ npm install @pinia/testing --save-dev`
一旦安装完成,您应该会看到@pinia/testing
已经作为开发依赖项添加到 package.json 中:
`"devDependencies": { "@pinia/testing": "^0.0.14", }`
项目结构
在 Vue 项目中,“src”文件夹包含 Vue 组件和 Pinia 数据存储:
`% tree -L 2 src
src
├── App.vue
├── assets
│ ├── base.css
│ └── main.css
├── components
│ ├── CityList.vue
│ ├── ...
│ ├── WeatherHeader.vue
│ └── __tests__
│ ├── App.spec.js
│ ├── ...
│ └── WeatherHeader.spec.js
├── main.js
└── stores
├── __tests__
│ └── cities.spec.js
└── cities.js`
Pinia 数据存储在“src/stores”中。每个 Pinia 数据存储应该是“src/stores”文件夹中的一个单独的文件。
Pinia 数据存储的单元测试文件存储在“src/stores/tests”中。每个 Pinia 数据存储都应该有一个单独的单元测试文件。
数据存储
本教程将测试的 Pinia 数据存储存储不同城市的天气数据:
`import { defineStore } from 'pinia' export const useCitiesStore = defineStore('cities', { // state is the data being stored in the data store state: () => ({ // List of Objects representing the weather for cities: // - cityName: name of the city // - stateName: name of the state (if applicable) // - countryAbbreviation: abbreviation of the country // - weatherSummary: brief description of the current weather // - currentTemperature: current temperature (in degrees F) // - dailyHigh: high temperature (in degrees F) for today // - dailyLow: low temperature (in degrees F) for today weatherData: [] }), // getters return data from the data store getters: { getNumberOfCities: (state) => { return state.weatherData.length } }, // actions are operations that change the state actions: { addCity(city, state, country, summary, currentTemp, high, low) { // Check if the city is already saved if (this.weatherData.find(({ cityName }) => cityName === city) === undefined) { this.weatherData.push({ 'cityName': city, 'stateName': state, 'countryAbbreviation': country, 'weatherSummary': summary, 'currentTemperature': currentTemp, 'dailyHigh': high, 'dailyLow': low }) } }, clearAllCities() { // Setting the `weatherData` array to a length of zero clears it this.weatherData.length = 0 } } })`
属性定义了被存储的数据,这是一个代表不同城市天气的对象数组。
在getters
属性中有一个元素用于检索存储的城市数量。
有两个actions
用于修改正在存储的数据:
addCity()
-添加城市天气数据的动作clearAllCities()
-删除所有城市天气数据的动作
单元测试 Pinia 数据存储
单元测试结构
当对 Pinia 数据存储进行单元测试时,应通过导入来使用实际存储:
`import { describe, it, expect, beforeEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useCitiesStore } from '@/stores/cities' // <-- !! describe('Data Store Test', () => { let store = null beforeEach(() => { // create a fresh Pinia instance and make it active so it's automatically picked // up by any useStore() call without having to pass it to it: // `useStore(pinia)` setActivePinia(createPinia()) // create an instance of the data store store = useCitiesStore() }) it('initializes with correct values', () => { ... }) it('test adding a new city', () => { ...}) it('test adding a duplicate city', () => { ... }) it('test removing all cities', () => { ... }) })`
Pinia 数据存储被导入以在单元测试文件中使用:
`import { useCitiesStore } from '@/stores/cities'`
在
import
语句中使用的@
是 Vue 项目中“src”文件夹的别名。
在beforeEach()
函数(在每个单元测试函数之前执行)中,pinia
实例被创建并激活:
`beforeEach(() => { // create a fresh Pinia instance and make it active so it's automatically picked // up by any useStore() call without having to pass it to it: // `useStore(pinia)` setActivePinia(createPinia()) // create an instance of the data store store = useCitiesStore() })`
一旦pinia
被激活,Pinia 数据存储器的一个实例-即被测单元!-可以使用useCitiesStore()
创建。
单元测试
有了单元测试文件的结构,就可以编写单元测试来测试 getters 和 actions 了。
首先,检查 Pinia 数据存储是否用空数组初始化:
`it('initializes with zero cities', () => { expect(store.getNumberOfCities).toEqual(0) })`
接下来,测试使用addCity()
动作的名义场景:
`it('test adding a new city', () => { // Call the 'addCity' action store.addCity('Chicago', 'Illinois', 'US', 'cloudy', 75.6, 78.9, 65.2) // Check that the city was added expect(store.getNumberOfCities).toEqual(1) expect(store.weatherData.length).toEqual(1) expect(store.weatherData[0]).toEqual({ 'cityName': 'Chicago', 'stateName': 'Illinois', 'countryAbbreviation': 'US', 'weatherSummary': 'cloudy', 'currentTemperature': 75.6, 'dailyHigh': 78.9, 'dailyLow': 65.2 }) })`
此外,在使用addCity()
动作时,测试城市已经在weatherData
数组中的非名义场景也是一个好主意:
`it('test adding a duplicate city', () => { // Call the 'addCity' action store.addCity('New Orleans', 'Louisiana', 'US', 'sunny', 87.6, 78.9, 65.2) // Check that the city was added expect(store.weatherData.length).toEqual(1) expect(store.weatherData[0].cityName).toMatch('New Orleans') // Attempt to add the same city store.addCity('New Orleans', 'Louisiana', 'US', 'sunny', 87.6, 78.9, 65.2) // Check that only 1 instance of the city name is saved expect(store.weatherData.length).toEqual(1) expect(store.weatherData[0].cityName).toMatch('New Orleans') })`
最后,用clearAllCities()
动作测试从存储中删除所有数据:
`it('test removing all cities', () => { // Add two cities to the data store store.addCity('New Orleans', 'Louisiana', 'US', 'sunny', 87.6, 78.9, 65.2) store.addCity('Denver', 'Colorado', 'US', 'windy', 94.5, 95.6, 56.7) // Check that the cities were added expect(store.weatherData.length).toEqual(2) // Remove a city store.clearAllCities() // Check that zero cities remain in the data store expect(store.weatherData.length).toEqual(0) })`
当对 Pinia 数据存储进行单元测试时,测试应该关注 Vue 组件将如何使用数据存储。通常,这些单元测试应该集中在修改数据存储的状态的动作上。
使用 Pinia 数据存储进行测试
概观
当测试使用 Pinia 数据存储的 Vue 组件时,应该使用createTestingPinia
插件来创建一个 Pinia 实例,该实例是为测试而设计的。
具体来说,所有的 Pinia 数据存储都将被嘲笑,因此您可以专注于测试 Vue 组件。这种方法意味着 Vue 组件测试应该关注与 Pinia 数据存储的两个关键交互:
- 安装组件时初始化 Pinia 数据存储
- 监视 Pinia 数据存储中的动作,以确保它们在适当的时间被调用
单元测试
当单元测试使用 Pinia 数据存储的 Vue 组件时,您需要从 Pinia 测试模块导入createTestingPinia
:
`import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { shallowMount } from '@vue/test-utils' import CityList from '@/components/CityList.vue' import { createTestingPinia } from '@pinia/testing' // <-- !! import { useCitiesStore } from '@/stores/cities'`
除了createTestingPinia
之外,还需要从 Pinia 数据存储中导入useCitiesStore()
函数,以测试数据存储中是否调用了正确的“动作”。
本教程将介绍两个单元测试套件(复杂性逐渐增加)。
第一个单元测试套件测试当 Pinia 数据存储为空时 Vue 组件如何工作:
`describe('CityList.vue Test with empty data store', () => { let wrapper = null // SETUP - run prior to each unit test beforeEach(() => { // render the component wrapper = shallowMount(CityList, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn }) ] } }) }) // TEARDOWN - run after each unit test afterEach(() => { wrapper.unmount() }) it('initializes with zero elements displayed', () => { // check that zero city cards are displayed expect(wrapper.findAll('h2').length).toEqual(0) // check that the 'Clear Weather Data' button is not displayed expect(wrapper.findAll('button').length).toEqual(1) expect(wrapper.findAll('button')[0].isVisible()).toBeFalsy() }) })`
当安装组件(使用shallowMount()
)时,利用createTestingPinia()
创建对所有动作的监视:
`// render the component wrapper = shallowMount(CityList, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn }) ] } })`
该单元测试套件中的单个单元测试检查零卡是否显示,以及“清除天气数据”按钮是否显示:
`it('initializes with zero elements displayed', () => { // check that zero city cards are displayed expect(wrapper.findAll('h2').length).toEqual(0) // check that the 'Clear Weather Data' button is not displayed expect(wrapper.findAll('button').length).toEqual(1) expect(wrapper.findAll('button')[0].isVisible()).toBeFalsy() })`
第二个单元测试套件用两个城市的天气数据初始化 Pinia 数据存储:
`describe('CityList.vue Test with filled data store', () => { let wrapper = null let store = null // SETUP - run prior to each unit test beforeEach(() => { // render the component and initialize the data store // to contain weather data for (2) cities wrapper = shallowMount(CityList, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn, initialState: { cities: { weatherData: [ { 'cityName': 'New Orleans', 'stateName': 'Louisiana', 'countryAbbreviation': 'US', 'weatherSummary': 'sunny', 'currentTemperature': 77.6, 'dailyHigh': 78.9, 'dailyLow': 65.2 }, { 'cityName': 'Dublin', 'stateName': '', 'countryAbbreviation': 'IE', 'weatherSummary': 'windy', 'currentTemperature': 64.5, 'dailyHigh': 65.6, 'dailyLow': 46.7 } ] } } }) ] } }) // create the data store using the testing pinia store = useCitiesStore() }) // TEARDOWN - run after each unit test afterEach(() => { wrapper.unmount() }) it('displays city weather from the data store', () => { ... }) it('calls the correct action when the weather data is cleared', async () => { ... }) })`
当 Vue 组件被安装(通过shallowMount()
)时,createTestingPinia()
用于创建对所有动作的监视。此外,定义了initialState
属性来初始化 Pinia 数据存储:
`// render the component and initialize the data store // to contain weather data for (2) cities wrapper = shallowMount(CityList, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn, initialState: { cities: { weatherData: [ { 'cityName': 'New Orleans', 'stateName': 'Louisiana', 'countryAbbreviation': 'US', 'weatherSummary': 'sunny', 'currentTemperature': 77.6, 'dailyHigh': 78.9, 'dailyLow': 65.2 }, { 'cityName': 'Dublin', 'stateName': '', 'countryAbbreviation': 'IE', 'weatherSummary': 'windy', 'currentTemperature': 64.5, 'dailyHigh': 65.6, 'dailyLow': 46.7 } ] } } }) ] } })`
在指定initialState
属性时,指定的对象需要以 Pinia 数据商店名称开始(在本例中,cities
是在 src/stores/cities.js 中使用defineStore()
创建商店时指定的名称)。接下来,指定“状态”(在这种情况下,weatherData
)并指定数据的初始数组。
指定了 Pinia 数据存储的首字母state
后,该测试套件中的第一个单元测试检查来自存储的数据是否正确显示:
`it('displays city weather from the data store', () => { // check that two city cards are displayed const cityHeadings = wrapper.findAll('h2') expect(cityHeadings.length).toEqual(2) expect(cityHeadings[0].text()).toMatch('New Orleans, Louisiana') expect(cityHeadings[1].text()).toMatch('Dublin') const citySubHeadings = wrapper.findAll('h3') expect(citySubHeadings.length).toEqual(2) expect(citySubHeadings[0].text()).toMatch('US') expect(citySubHeadings[1].text()).toMatch('IE') const cityWeatherData = wrapper.findAll('p') expect(cityWeatherData.length).toEqual(6) expect(cityWeatherData[0].text()).toMatch('Weather Summary: sunny') expect(cityWeatherData[1].text()).toMatch('Current Temperature: 77.6') expect(cityWeatherData[2].text()).toMatch('High: 78.9°F / Low: 65.2°F') expect(cityWeatherData[3].text()).toMatch('Weather Summary: windy') expect(cityWeatherData[4].text()).toMatch('Current Temperature: 64.5') expect(cityWeatherData[5].text()).toMatch('High: 65.6°F / Low: 46.7°F') // check that the 'Clear Weather Data' button is displayed expect(wrapper.findAll('button').length).toEqual(1) expect(wrapper.findAll('button')[0].isVisible()).toBeTruthy() expect(wrapper.findAll('button')[0].text()).toMatch('Clear Weather Data (2)') })`
第二个单元测试检查单击“清除天气数据”按钮时是否调用了正确的“操作”:
`it('calls the correct action when the weather data is cleared', async () => { // create the data store using the testing pinia const store = useCitiesStore() // trigger an event when the 'Clear Weather Data' button is clicked wrapper.findAll('button').at(0).trigger('click') // check that the 'clearAllCities' action was called on the data store expect(store.clearAllCities).toHaveBeenCalledTimes(1) })`
请注意,该测试并不检查 Pinia 数据存储中的数据是否发生了变化,因为我们已经在 Pinia 数据存储的单元测试中对此进行了测试。当测试 Vue 组件时,我们关心的是如何与 Pinia 数据存储交互(即,是否调用了正确的操作?).
其他注意事项
如果您对您的 Vue 应用程序(使用mount()
)进行顶级测试,它还需要创建一个 Pinia 的测试版本,因为与 Pinia 数据存储交互的 Vue 子组件将在测试期间安装。
下面是一个顶级测试文件的示例,它检查是否呈现了正确的组件:
`import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import App from '@/App.vue' import { createTestingPinia } from '@pinia/testing' describe('App.vue Test', () => { it('renders the page', () => { // render the component const wrapper = mount(App, { global: { plugins: [ createTestingPinia({ createSpy: vi.fn }) ] } }) // check that all 3 sub-components are rendered expect(wrapper.getComponent({ name: 'WeatherHeader' }).exists()).toBeTruthy() expect(wrapper.getComponent({ name: 'WeatherContent' }).exists()).toBeTruthy() expect(wrapper.getComponent({ name: 'WeatherFooter' }).exists()).toBeTruthy() }) })`
就像前面的例子一样,App
组件需要用createTestingPinia
插件mount
编辑。这允许与 Pinia 数据存储交互的任何子组件与测试版本的 Pinia 一起执行。
运行测试
Vitest 可用于运行单元测试,如下所示:
`$ npm run test:unit
...
✓ src/components/__tests__/CitySearch.spec.js (5)
✓ src/components/__tests__/WeatherBanner.spec.js (5)
✓ src/components/__tests__/WeatherFooter.spec.js (2)
✓ src/components/__tests__/CityList.spec.js (3)
✓ src/components/__tests__/WeatherHeader.spec.js (1)
✓ src/components/__tests__/WeatherContent.spec.js (5)
✓ src/components/__tests__/App.spec.js (1)
✓ src/stores/__tests__/cities.spec.js (4)
Test Files 8 passed (8)
Tests 26 passed (26)
Time 3.44s (in thread 801ms, 429.95%)
PASS Waiting for file changes...
press h to show help, press q to quit`
太棒了。我们可以成功地运行我们的单元测试。
通过
npm
为你的 Vue 项目运行的所有可用命令都在 package.json 的scripts
字段中定义。
Vitest 的默认配置是在监视模式下运行测试,这意味着每次保存到一个适用的文件时,测试套件都会被重新执行。要更改此配置,使 Vitest 只运行一次(没有“监视模式”),请更新 package.json 中的test:unit
配置,以包含run
参数:
`"test:unit": "vitest run --environment jsdom",`
运行相同的npm run test:unit
命令现在将导致 Vitest 运行一次,然后完成其执行(没有“监视模式”)。
结论
Pinia 数据存储是在 Vue 应用程序中存储全局数据的好方法。
测试 Pinia 数据存储应分为两类:
- 数据存储的单元测试——测试“getters”和“actions”是否正常工作
- 使用数据存储的 Vue 组件的单元测试——测试检索数据和正确调用“动作”
虽然配置单元测试文件以与 Pinia 一起工作有些复杂,但是测试 Pinia 数据存储可以在您的 Vue 应用程序的一个关键方面提供信心。
同样,如果你有兴趣了解更多关于 Vue 的知识,可以看看我的课程:通过构建和部署 CRUD 应用来学习 Vue。
这是测试 Vue 应用的两部分系列的第二部分:
Vue 组件单元测试指南
这篇文章是单元测试组件的指南。
我们将首先看看为什么单元测试对于创建可维护的软件是重要的,以及你应该测试什么。然后,我们将详细介绍如何:
- 为 Vue 组件创建并运行单元测试
- 测试 Vue 组件的不同方面
- 使用模拟来测试异步功能
- 检查单元测试的代码覆盖率
- 构建您的单元测试文件
如果你有兴趣了解更多关于 Vue 的知识,可以看看我的课程:通过构建和部署 CRUD 应用来学习 Vue。
依赖关系:
本文中使用的 Vue Weather App 项目的源代码(以及详细的安装说明)可以在 GitLab 上找到: Vue Weather App 。
这是测试 Vue 应用的两部分系列的第一部分:
目标
完成本文后,您应该能够:
- 解释为什么单元测试很重要
- 描述你应该(和不应该)单元测试什么
- 为 Vue 组件开发单元测试套件
- 为 Vue 项目运行单元测试
- 利用单元测试套件中的
beforeEach()
和afterEach()
函数 - 编写单元测试来测试 Vue 组件的实现细节
- 编写单元测试来测试 Vue 组件的行为方面(点击事件等)。)
- 解释嘲讽如何帮助简化单元测试
- 编写模拟库和测试异步函数的单元测试
- 检查单元测试的代码覆盖率
- 为测试 Vue 组件开发一个结构良好的单元测试文件
为什么要进行单元测试?
总的来说,测试有助于确保你的应用程序能像最终用户期望的那样工作。
具有高测试覆盖率的软件项目从来都不是完美的,但是它是软件质量的一个很好的初始指示器。此外,可测试的代码通常是一个好的软件架构的标志,这就是高级开发人员在整个开发生命周期中考虑测试的原因。
测试可以分为三个层次:
- 单位
- 综合
- 端到端
单元测试测试独立于依赖项的单个代码单元的功能。它们是防止代码库中出现错误和不一致的第一道防线。单元测试是测试驱动开发过程的基础部分。
单元测试提高了代码的可维护性。
可维护性指的是对您的代码或将来需要更新您的代码的其他开发人员进行错误修复或增强。
单元测试应该与持续集成 (CI)过程相结合,以确保您的单元测试不断地被执行,最好是在每次提交到您的存储库时。一套可靠的单元测试对于在最终用户在生产中遇到缺陷之前,在开发过程的早期快速捕捉缺陷是至关重要的。
测试什么
你应该测试什么?或者,更重要的是:你不应该测试什么?
对于单元测试,有三种类型的测试需要考虑:
- 实现细节:组件用来根据给定的输入产生结果的底层业务逻辑
- 公共接口/设计合同:特定的输入(或道具,在这种情况下)产生特定的结果
- 副作用:“如果这样,那就那样”;例如,当点击一个按钮时,会发生一些事情
既然你不能测试所有的东西,你应该关注什么?
重点测试最终用户将与之交互的输入和输出。你的产品的用户体验是最重要的!
通过集中测试软件模块的输入/输出(例如,Vue 组件),您正在测试最终用户将体验的关键方面。
软件模块中可能有其他复杂的内部逻辑,需要进行单元测试,但是这些类型的测试很可能需要在代码重构期间进行更新。
应用概述
在讨论如何对 Vue 组件进行单元测试之前,我想简单介绍一下我们将要测试的 Vue 天气应用。
在克隆存储库之后,安装依赖项,并添加 API 密钥。
查看项目的自述文件以获得更多关于从 Open Weather 创建和添加 API 密钥的信息。
完成后,可以通过启动开发服务器在本地计算机上运行 Vue Weather 应用程序:
应用程序构建完成后,您将看到类似于以下内容的成功消息:
`vite v2.9.14 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose
ready in 543ms.`
此时,开发服务器将启动并运行。你可以通过在你最喜欢的网络浏览器中导航到 http://localhost:3000 来查看 Vue 应用。第一次加载 app 时,没有数据显示;您将会看到一个输入字段,用于输入您想要获取天气信息的城市:
如果没有输入任何数据,“搜索”和“清除”按钮都将被禁用。
一旦您开始输入一个城市的数据(一旦添加了第一个字符),“搜索”和“清除”按钮都将被启用:
如果您在输入有效城市后点击“搜索”,将显示该城市的天气数据:
此时,单击“清除天气数据”按钮将会清除天气数据:
但是,输入的城市将保留在输入字段中。
如果您现在单击“清除”,输入字段将被清除,“搜索”和“清除”按钮将再次被禁用:
如果输入无效城市会怎样?如果你在“清除天气数据”前点击“清除”会怎样?你还能找到 app 的哪些状态?
Vue 中的单元测试
因为组件是 Vue(实际上是任何 SPA 框架)的组成部分,所以它们是整个应用程序中最关键的部分。所以,把分配给你的大部分测试时间花在编写测试应用组件的单元测试上。
根据我对 Vue 组件进行单元测试的经验,我发现测试工具和框架——Vite 和 Vitest——满足了良好测试环境的关键方面:
- 编写测试既简单又有趣
- 测试可以快速编写
- 可以用一个命令来执行测试
- 测试运行迅速
希望你会发现对你的 Vue 组件进行单元测试是一种愉快的经历,因为我认为这是鼓励更多测试的关键。
我们将使用两种工具对 Vue 组件进行单元测试:
- Vitest -单元测试框架
- Vue 测试工具-Vue 的单元测试工具库
Vitest 是一个单元测试框架,其目标是与框架无关的,因此它可以用于测试 Vue、React、Svelte、Lit 和其他项目。Vitest 被配置为与 Vite 一起运行,这导致了快速的测试执行。
如果您熟悉 Jest,切换到使用 Vitest 非常简单,因为 Vitest 的 API 与 Jest 兼容。
为 Vue 组件编写单元测试时,Vitest 和 Vue 测试实用程序提供了以下功能:
Vitest
- 运行测试和检查测试覆盖率的命令行工具
- 直观地查看测试结果和覆盖率结果的用户界面
- 编写单元测试的函数(
it
,describe
) - 对照期望值进行检查的功能(
expect
、toMatch
、toContain
等)。) - 嘲讽(
mockResolvedValue
,mockRejectedValue
) - 设置(
beforeEach
、beforeAll
) /拆卸(afterEach
、【T3”)
vista 测试有用
- 安装组件(
mount
、shallowMount
) - 设置道具数据(
setProps
) - 寻找用于测试的 HTML 组件(
findAll('h2')
) - 清除所有承诺的实用程序(
flushPromises()
) - 触发点击事件的实用程序(
trigger
) - 检查发出事件的实用程序(
emitted
)
Vitest 提供了编写单元测试的通用功能,而 Vue Test Utils 提供了特定于 Vue 的测试工具。
单元测试概述
首先,让我们讨论一下 Vue 中单元测试的命名约定。单元测试文件应该采用以下格式:
根据 Vue 风格指南,组件名应该是多个单词(
WeatherHeader
而不是仅仅Header
),以防止与 HTML 元素冲突。
通常,Vue 项目中的每个组件都应该有一个单元测试文件。在每个单元测试文件中,可以有单个单元测试套件或多个单元测试套件。
单元测试文件应该放在组件文件夹( src/components/tests )的子文件夹中:
`$ tree -d -L 2
.
├── node_modules
├── public
├── src
├── assets
└── components
└── __tests__`
运行测试
Vitest 可用于运行单元测试,如下所示:
`$ npm run test:unit
✓ src/components/__tests__/WeatherFooter.spec.js (1)
✓ src/components/__tests__/WeatherHeader.spec.js (1)
✓ src/components/__tests__/WeatherResult.spec.js (3)
✓ src/components/__tests__/WeatherBanner.spec.js (5)
✓ src/components/__tests__/WeatherSearch.spec.js (5)
✓ src/components/__tests__/App.spec.js (7)
Test Files 6 passed (6)
Tests 22 passed (22)
Time 2.38s (in thread 640ms, 371.13%)`
通过
npm
为你的 Vue 项目运行的所有可用命令都在 package.json 的scripts
字段中定义。
Vitest 的默认配置是在监视模式下运行测试,这意味着每次保存到一个适用的文件时,测试套件都会被重新执行。要更改此配置,使 Vitest 只运行一次(没有“监视模式”),请更新 package.json 中的test:unit
配置,以包含run
参数:
`"test:unit": "vitest run --environment jsdom",`
例子
使用 Vue 天气应用,让我们看一些测试 Vue 组件的例子。
示例 1 -单元测试介绍
让我们直接看一个 Vue 中单元测试文件的例子!第一个单元测试文件位于src/components/_ _ tests _ _/weather header . spec . js中,它测试WeatherHeader
组件:
`import { describe, it, expect } from 'vitest' import { shallowMount } from '@vue/test-utils' import WeatherHeader from '../WeatherHeader.vue' describe('WeatherHeader.vue Test', () => { it('renders message when component is created', () => { // render the component const wrapper = shallowMount(WeatherHeader, { propsData: { title: 'Vue Project' } }) // check that the title is rendered expect(wrapper.text()).toMatch('Vue Project') }) })`
增加
该文件中的第一行从 Vitest 中导入该文件中使用的测试函数。
如果你想让 Vitest API 全球可用(比如 Jest 是如何工作的),那么在 vite.config.js 中的
defineConfig()
函数中添加test: {globals: true}
。更多详情,请查看 Vitest 文档。
该文件的第二行从 Vue Test Utils 库中导入了一个名为shallowMount
的函数。“安装”的概念意味着加载单个组件,以便能够对其进行测试。在 Vue 测试工具中有两种方法:
shallowMount()
-为 Vue 组件创建一个wrapper
,但是带有存根子组件mount()
-为 Vue 组件创建一个wrapper
,包括安装任何子组件
因为我们的重点是测试单个组件(WeatherHeader
组件),所以我们将使用shallowMount()
。
shallowMount()
更适合单独测试单个组件,因为子组件被剔除了。这是单元测试的理想情况。此外,使用
shallowMount()
来测试一个有很多子组件的组件可以改善单元测试的执行时间,因为渲染子组件没有成本(就时间而言)。当你想要测试子组件的行为时,
mount()
是有用的。
第三行导入将要测试的 Vue 组件, WeatherHeader.vue 。
描述块
在import
语句之后,有一个定义单元测试套件的describe
块。
在一个单元测试文件中,可以有多个定义不同单元测试套件的describe
块。类似地,每个describe
块可以包含多个单元测试,其中每个单元测试由一个it
块定义。
我认为这种区别是:
- 块单元测试套件
it
块-单个单元测试功能
使用 Vitest 进行单元测试的好处是有几个内置的鼓励添加注释的方法。例如,describe
的第一个参数应该清楚地解释哪个 Vue 组件正在被测试:
`describe('WeatherHeader.vue Test', () => { ... })`
对于每个it
块,第一个参数是测试函数的描述,它应该是这个特定测试正在做什么的简短描述。在上面的例子中,it
块测试组件“在创建组件时呈现消息”。
预计
至于实际的单元测试,第一步是安装 Vue 组件,以便可以对其进行测试:
`// render the component const wrapper = shallowMount(WeatherHeader, { propsData: { title: 'Vue Project' } })`
shallowMount
函数返回一个wrapper
对象,它包含已安装的组件和测试该组件的方法。wrapper
对象允许我们测试 Vue 组件生成的 HTML 的所有方面以及 Vue 组件的所有属性(比如数据)。
此外,传递到WeatherHeader
组件中的 props 作为第二个参数传递给shallowMount()
。
单元测试中执行的实际检查是:
`// check that the title is rendered expect(wrapper.text()).toMatch('Vue Project')`
这一行使用wrapper
检查组件生成的标题是否为‘Vue Project’。由于该检查进行字符串比较,所以建议使用toMatch()
。
测试助手
虽然单元测试文件中对组件WeatherHeader
的检查只是检查字符串值,但是 Vitest 中有很多选项可以用来执行检查:
- 布尔值:
toBeTruthy()
-检查变量/语句是否为真toBeFalsy()
-检查变量/语句是否为假
- 已定义:
toBeNull()
-检查变量是否只匹配空值toBeUndefined()
-检查变量是否未定义toBeDefined()
-检查变量是否已定义
- 数字:
toBeGreaterThan()
-检查数字是否大于指定值toBeGreaterThanOrEqual()
-检查数字是否大于或等于指定值toBeLessThan()
-检查数字是否小于指定值toBeLessThanOrEqual()
-检查数字是否小于或等于指定值toBe()
和toEqual()
-检查数字是否与指定的值相同(这些函数对于数字是等效的)toBeCloseTo()
-检查一个数字是否等于小容差内指定的值(对浮点数有用)
- 字符串:
toMatch()
-检查一个字符串是否等于指定的值(Regex 可以用作指定的值!)
- 数组:
toContain()
-检查数组是否包含指定的值
此外,not
限定符可以用于大多数检查:
`expect(wrapper.text()).not.toMatch('Node Project')`
有关可用检查的完整列表,请查看 Vitest API 参考。
示例 2 -测试初始条件
这个例子展示了如何测试WeatherResult
组件的初始条件(或状态)。
下面是单元测试文件的概要(在src/components/_ _ tests _ _/weather result . spec . js中定义):
`import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { shallowMount, flushPromises } from '@vue/test-utils' import WeatherResult from '@/components/WeatherResult.vue' describe('WeatherResult.vue Implementation Test', () => { let wrapper = null // SETUP - run before to each unit test beforeEach(() => { // render the component wrapper = shallowMount(WeatherResult, { propsData: { city: '', weatherSummary: '', weatherDescription: '', currentTemperature: 0.0, lowTemperature: 0.0, highTemperature: 0.0 } }) }) // TEARDOWN - run after to each unit test afterEach(() => { wrapper.unmount() }) it('initializes with correct elements', () => { ... }) it('processes valid props data', async () => { ... }) it('emits a custom event when the Clear Weather Data button is clicked', () => { ... }) })`
单元测试文件利用shallowMount()
函数来呈现WeatherResult
组件,因为这个组件是作为一个独立的组件来测试的。
每个块之前和每个块之后
在单元测试套件中(在describe
块中定义),定义了两个新函数:
beforeEach()
-在执行之前被称为的本单元测试套件内的每个单元测试afterEach()
——在执行完本单元测试套件内的个单元测试后,称为
**beforeEach()
函数用于在运行每个单元测试之前设置一致的状态。这个概念对于确保运行单元测试的顺序不会影响单元测试的整体结果非常重要。
在这个例子中,beforeEach()
函数使用一组默认的属性数据来呈现组件:
`// SETUP - run before to each unit test beforeEach(() => { // render the component wrapper = shallowMount(WeatherResult, { propsData: { city: '', weatherSummary: '', weatherDescription: '', currentTemperature: 0.0, lowTemperature: 0.0, highTemperature: 0.0 } }) })`
afterEach()
函数用于清除单元测试期间执行的任何处理。
在这个例子中,afterEach()
函数卸载单元测试中使用的wrapper
,这样wrapper
可以在beforeEach()
中为下一个单元测试重新初始化:
`// TEARDOWN - run after to each unit test afterEach(() => { wrapper.unmount() })`
如果希望运行在整个单元测试套件运行之前或之后执行的代码,可以使用:
`beforeAll(() => { /* Runs before all tests */ }) afterAll(() => { /* Runs after all tests */ })`
预计
第一个单元测试检查WeatherResult
组件的初始状态:
`it('initializes with correct elements', () => { // check that the heading text is rendered expect(wrapper.findAll('h2').length).toEqual(2) expect(wrapper.findAll('h2').at(0).text()).toMatch('Weather Summary') expect(wrapper.findAll('h2').at(1).text()).toMatch('Temperatures') // check that 6 fields of data for the temperature are displayed expect(wrapper.findAll('p').length).toEqual(6) expect(wrapper.findAll('p').at(0).text()).toMatch('City:') expect(wrapper.findAll('p').at(1).text()).toMatch('Summary:') expect(wrapper.findAll('p').at(2).text()).toMatch('Details:') expect(wrapper.findAll('p').at(3).text()).toMatch('Current: 0° F') expect(wrapper.findAll('p').at(4).text()).toMatch('High (Today): 0° F') expect(wrapper.findAll('p').at(5).text()).toMatch('Low (Today): 0° F') })`
检查:
expect
s 的第一部分检查两个标题(定义为h2
元素)是否符合预期。expect
s 的第二部分检查六个数据字段(定义为p
元素)是否符合预期。
示例 3 -测试道具
第二个单元测试检查作为正确数据传入的有效数据是否被WeatherResult
组件正确处理:
`it('processes valid props data', async () => { // Update the props passed in to the WeatherResult component wrapper.setProps({ city: 'Chicago', weatherSummary: 'Cloudy', weatherDescription: 'Cloudy with a chance of rain', currentTemperature: 45.1, lowTemperature: 42.0, highTemperature: 47.7 }) // Wait until the DOM updates await flushPromises() // check that the prop data is stored as expected within the component expect(wrapper.vm.city).toMatch('Chicago') expect(wrapper.vm.weatherSummary).toMatch('Cloudy') expect(wrapper.vm.weatherDescription).toMatch('Cloudy with a chance of rain') expect(wrapper.vm.currentTemperature).toEqual(45.1) expect(wrapper.vm.lowTemperature).toBeCloseTo(42.0) expect(wrapper.vm.highTemperature).toBe(47.7) // check that the heading text is rendered expect(wrapper.findAll('h2').length).toEqual(2) expect(wrapper.findAll('h2').at(0).text()).toMatch('Weather Summary') expect(wrapper.findAll('h2').at(1).text()).toMatch('Temperatures') // check that 6 fields of data for the temperature are displayed expect(wrapper.findAll('p').length).toEqual(6) expect(wrapper.findAll('p').at(0).text()).toMatch('City: Chicago') expect(wrapper.findAll('p').at(1).text()).toMatch('Summary: Cloudy') expect(wrapper.findAll('p').at(2).text()).toMatch('Details: Cloudy with a chance of rain') expect(wrapper.findAll('p').at(3).text()).toMatch('Current: 45.1° F') expect(wrapper.findAll('p').at(4).text()).toMatch('High (Today): 47.7° F') expect(wrapper.findAll('p').at(5).text()).toMatch('Low (Today): 42° F') })`
由于beforeEach()
函数提供了一组默认的属性数据,我们需要使用setProps()
函数覆盖属性数据。
为了确保 prop 数据在WeatherResult
中引起预期的更新,测试需要等待所有的 DOM 更新生效:
`// Wait until the DOM updates await flushPromises()`
注意:只有用
async
定义函数时,才能使用await
!
检查:
- 随着道具数据的更新,我们可以通过检查数据元素(使用
wrapper.vm
)来检查道具数据是否正确地存储在WeatherResult
组件中。 expect
s 的第二部分检查两个标题(定义为h2
元素)是否符合预期。- 最后一组
expect
检查 prop 数据是否用于按预期设置六个数据字段(定义为p
元素)。
示例 4 -测试用户输入(点击事件)
第三个单元测试检查当用户点击“清除天气数据”按钮时,WeatherResult
组件发出了clear-weather-data
事件:
`it('emits a custom event when the Clear Weather Data button is clicked', () => { // trigger an event when the 'Clear Weather Data' button is clicked wrapper.findAll('button').at(0).trigger('click') // check that 1 occurrence of the event has been emitted expect(wrapper.emitted('clear-weather-data')).toBeTruthy() expect(wrapper.emitted('clear-weather-data').length).toBe(1) })`
要触发点击事件,必须在wrapper
中找到button
元素,然后调用trigger
函数来触发点击事件。
单击按钮后,单元测试会检查是否只发出了一个定制事件(名称为clear-weather-data
)。
嘲弄的例子
在App
组件中,当用户搜索一个城市的天气时,HTTP GET 调用 Open Weather 通过名为 Axios 的第三方库检索数据:
`const searchCity = (inputCity) => { // GET request for user data axios.get('http://api.openweathermap.org/data/2.5/weather?q=' + inputCity + '&units=imperial&APPID=' + openweathermapApiKey.value) .then((response) => { // handle success console.log(response) weatherData.value.city = response.data.name weatherData.value.weatherSummary = response.data.weather[0].main weatherData.value.weatherDescription = response.data.weather[0].description weatherData.value.currentTemperature = response.data.main.temp weatherData.value.lowTemperature = response.data.main.temp_min weatherData.value.highTemperature = response.data.main.temp_max validWeatherData.value = true }) .catch((error) => { // handle error messageType.value = 'Error' messageToDisplay.value = 'ERROR! Unable to retrieve weather data for ' + inputCity + '!' console.log(error.message) resetData() }) .finally((response) => { // always executed console.log('HTTP GET Finished!') }) }`
当考虑如何测试 HTTP GET 调用时,两个场景浮现在脑海中,每个场景都测试实际 API 调用的副作用:
- HTTP GET 响应成功(快乐之路)
- HTTP GET 响应失败(异常路径)
当测试利用外部 API 的代码时,通常更容易的方法是不进行实际的调用,而是用一个模拟来代替调用。不过,这种方法有利也有弊。
优点:
- 这些测试不依赖于网络请求
- 如果 API 下降,它们也不会中断
- 他们会跑得更快
缺点:
- 每当 API 模式改变时,您都需要更新测试
- 在微服务架构中,很难跟上 API 的变化
- 模仿是一个很难理解的概念,它们会给你的测试套件增加很多混乱
在某些时候,您应该检查完全集成,以确保 API 响应的形式没有改变。
由于这篇文章关注的是单元测试,我们将模仿Axios 库。
模仿提供了一种模仿软件模块预期行为的方法。虽然嘲讽可以用在生产代码中(非常危险!),它通常在开发和测试期间使用。
从外部 API 加载数据需要时间。虽然在这个应用程序中从 Open Weather 加载数据通常不到一两秒钟,但其他外部 API 可能会更耗时。此外,我们需要一种方法来轻松检查 HTTP GET 请求是否失败。因此,我们将添加模拟来指定 GET 请求将如何响应。
示例 5 -测试异步代码(成功案例)
组件的单元测试位于src/components/_ _ tests _ _/app . spec . js文件中。
首先,我们需要import
Axios 库:
`import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { shallowMount, mount, flushPromises } from '@vue/test-utils' import App from '../../App.vue' import axios from 'axios'`
但是,我们不想像在源代码中那样使用实际的 Axios 库( App.vue )。相反,我们想创建一个 Axios 库的模拟,这样我们就不会实际调用外部 API:
`// Mock the axios library vi.mock("axios", () => { return { default: { get: vi.fn(), }, }; });`
这个代码块告诉 Vitest 应该模拟axios
库中的get()
方法。
在单元测试文件(src/components/_ _ tests _ _/app . spec . js)中,对于App
组件,我们是:
- 模拟成功的 HTTP GET 请求
- 模拟失败的 HTTP GET 请求
首先,让我们处理 HTTP GET 请求成功的名义情况。
`describe('Implementation Test for App.vue with Successful HTTP GET', () => { let wrapper = null beforeEach(() => { const responseGet = { data: { name: 'Chicago', weather: [ { main: 'Cloudy', description: 'Cloudy with a chance of rain' } ], main: { temp: 56.3, temp_min: 53.8, temp_max: 58.6 } } } // Set the mock call to GET to return a successful GET response axios.get.mockResolvedValue(responseGet) // render the component wrapper = shallowMount(App) }) afterEach(() => { axios.get.mockReset() wrapper.unmount() }) ... })`
每个块之前和每个块之后
在beforeEach()
函数中,我们设置当axios.get()
被调用时应该发生的响应。响应是预先录制的天气数据,看起来类似于我们从开放天气中获得的数据,如果我们实际上发出请求的话。这一节的重点是:
`// Set the mock call to GET to return a successful GET response axios.get.mockResolvedValue(responseGet)`
这一行是关键,因为它让 Vitest 知道,当在App
组件中调用axios.get()
时,它应该返回responseGet
数组,而不是实际使用 Axios 进行 HTTP GET 调用。
单元测试套件的下一部分定义了afterEach()
函数:
`afterEach(() => { axios.get.mockReset() wrapper.unmount() })`
在每个单元测试执行后调用的afterEach()
函数清除单元测试执行期间创建的axios.get()
的模拟。这种方法是一种很好的实践,可以在运行一个测试之后清理掉所有的模拟,这样任何后续的测试都可以从一个已知的状态开始。
预计
现在我们可以定义单元测试了:
`it('does load the weather data when a successful HTTP GET occurs', async () => { wrapper.vm.searchCity('Chicago') // Wait until the DOM updates await flushPromises() expect(axios.get).toHaveBeenCalledTimes(1) expect(axios.get).toBeCalledWith(expect.stringMatching(/Chicago/)) // check that the user data is properly set expect(wrapper.vm.weatherData.city).toMatch('Chicago') expect(wrapper.vm.weatherData.weatherSummary).toMatch('Cloudy') expect(wrapper.vm.weatherData.weatherDescription).toMatch('Cloudy with a chance of rain') expect(wrapper.vm.weatherData.currentTemperature).toEqual(56.3) expect(wrapper.vm.weatherData.lowTemperature).toEqual(53.8) expect(wrapper.vm.weatherData.highTemperature).toEqual(58.6) expect(wrapper.vm.validWeatherData).toBe(true) })`
因为我们已经经历了定义模拟和呈现组件的步骤(通过shallowMount()
),所以这个单元测试可以集中在执行检查上。
单元测试通过调用searchCity()
函数开始:
`wrapper.vm.searchCity('Chicago')`
为了确保searchCity()
函数在App
组件中引起预期的更新,测试需要等待所有的承诺被解析,并且等待 DOM 更新生效:
`// Wait until all Promises are resolved and the DOM updates await flushPromises()`
我们检查了axios.get()
只被调用了一次,并且 HTTP GET 调用包含了正确的城市名:
`expect(axios.get).toHaveBeenCalledTimes(1) expect(axios.get).toBeCalledWith(expect.stringMatching(/Chicago/))`
为了非常彻底,天气数据还检查了在这个单元测试中呈现的App
组件的实例,以确保它与从axios.get()
的模拟返回的预先封装的数据相匹配:
`// check that the user data is properly set expect(wrapper.vm.weatherData.city).toMatch('Chicago') expect(wrapper.vm.weatherData.weatherSummary).toMatch('Cloudy') expect(wrapper.vm.weatherData.weatherDescription).toMatch('Cloudy with a chance of rain') expect(wrapper.vm.weatherData.currentTemperature).toEqual(56.3) expect(wrapper.vm.weatherData.lowTemperature).toEqual(53.8) expect(wrapper.vm.weatherData.highTemperature).toEqual(58.6) expect(wrapper.vm.validWeatherData).toBe(true)`
示例 6 -测试异步代码(失败案例)
虽然测试事情是否按预期进行很好,但是检查我们的软件如何对负面情况做出反应也很重要。记住这一点,让我们创建第二个单元测试套件来检查失败的 HTTP GET 请求:
`describe('Implementation Test for App.vue with Failed HTTP GET', () => { ... })`
在一个单元测试文件( .spec.js )中包含多个单元测试套件没有任何问题。
因为模拟是在
beforeEach()
函数中创建的,所以对于成功和失败的 HTTP GET 响应,需要有不同的beforeEach()
实现的独立单元测试套件。
每个块之前和每个块之后
这个单元测试套件中的beforeEach()
函数非常不同:
`beforeEach(() => { // Set the mock call to GET to return a failed GET request axios.get.mockRejectedValue(new Error('BAD REQUEST')) // Render the component wrapper = shallowMount(App) })`
我们现在返回的不是从axios.get()
调用返回的响应,而是一个失败的Promise
对象,其响应为“错误请求”。
这个单元测试套件的afterEach()
函数与另一个单元测试套件相同:
`afterEach(() => { axios.get.mockReset() wrapper.unmount() })`
预计
下面是测试失败的 HTTP GET 请求的单元测试函数:
`it('does not load the weather data when a failed HTTP GET occurs', async () => { wrapper.vm.searchCity('Chicago') expect(axios.get).toHaveBeenCalledTimes(1) expect(axios.get).toBeCalledWith(expect.stringMatching(/Chicago/)) // Wait until the DOM updates await flushPromises() // Check that there is no user data loaded when the GET request fails expect(wrapper.vm.weatherData.city).toMatch(/^$/) expect(wrapper.vm.weatherData.weatherSummary).toMatch(/^$/) expect(wrapper.vm.weatherData.weatherDescription).toMatch(/^$/) expect(wrapper.vm.weatherData.currentTemperature).toEqual(0) expect(wrapper.vm.weatherData.lowTemperature).toEqual(0) expect(wrapper.vm.weatherData.highTemperature).toEqual(0) expect(wrapper.vm.validWeatherData).toBe(false) // check that the banner message indicates failure expect(wrapper.vm.messageToDisplay).toMatch('ERROR! Unable to retrieve weather data for Chicago!') expect(wrapper.vm.messageType).toMatch('Error') })`
就像在前面的单元测试套件中一样,我们检查只有一个axios.get()
实例被调用,然后检查没有天气数据被加载到App
组件中。
代码覆盖率
在开发单元测试时,了解实际测试了多少源代码是件好事。这个概念被称为代码覆盖率。
我需要非常明确的是,拥有一组覆盖 100%源代码的单元测试并不意味着代码得到了正确的测试。
这个度量意味着有很多单元测试,并且在开发单元测试上投入了大量的精力。单元测试的质量仍然需要通过代码检查来检查。
另一个极端是最小集合(或者没有!)是一个非常糟糕的指标。
Vitest 通过使用--coverage
标志来提供代码覆盖率。
为了方便地运行带有覆盖率结果的 Vitest,我喜欢在 package.json 的script
部分包含以下项目:
`{ "name": "vue-weather-app", "version": "1.0.0", "scripts": { ... "test:unit": "vitest run --environment jsdom", "test:coverage": "vitest run --environment jsdom --coverage", "test:ui": "vitest --environment jsdom --coverage --ui" }, ... }`
运行npm run test:unit
时,执行单元测试:
`$ npm run test:unit
...
✓ src/components/__tests__/WeatherBanner.spec.js (5)
✓ src/components/__tests__/WeatherSearch.spec.js (5)
✓ src/components/__tests__/WeatherFooter.spec.js (1)
✓ src/components/__tests__/WeatherHeader.spec.js (1)
✓ src/components/__tests__/WeatherResult.spec.js (3)
✓ src/components/__tests__/App.spec.js (7)
Test Files 6 passed (6)
Tests 22 passed (22)
Time 2.81s (in thread 536ms, 524.89%)`
当npm run test:coverage
运行时,执行单元测试并报告覆盖率:
`$ npm run test:coverage
...
Test Files 6 passed (6)
Tests 22 passed (22)
Time 3.38s (in thread 660ms, 512.10%)
--------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files | 99.15 | 96.55 | 100 | 99.15 |
src | 97.31 | 85.71 | 100 | 97.31 |
App.vue | 97.31 | 85.71 | 100 | 97.31 | 60-63
src/components | 100 | 100 | 100 | 100 |
WeatherBanner.vue | 100 | 100 | 100 | 100 |
WeatherFooter.vue | 100 | 100 | 100 | 100 |
WeatherHeader.vue | 100 | 100 | 100 | 100 |
WeatherResult.vue | 100 | 100 | 100 | 100 |
WeatherSearch.vue | 100 | 100 | 100 | 100 |
--------------------|---------|----------|---------|---------|-------------------`
npm run test:ui
将在您的默认 web 浏览器中加载测试和覆盖结果,这可以方便地直观地看到测试覆盖缺失的地方。
单元测试结构
在浏览了许多不同的单元测试文件之后,我推荐 Vue 组件的单元测试文件采用以下结构:
`import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { shallowMount } from '@vue/test-utils' import App from '@/App.vue' // Import Vue component to test import axios from 'axios' // Import libraries to mock // Mock the axios library vi.mock("axios", () => { return { default: { get: vi.fn(), }, }; }); describe('Tests for the ... Component', () => { let wrapper = null beforeEach(() => { // set any initial data and create the mocks of libraries // render the component wrapper = shallowMount(App) }) afterEach(() => { axios.get.mockReset() wrapper.unmount() }) it('check successful events', () => { ... }) it('check failure events', () => { ... }) })`
关键项目:
- 每个单元测试套件都有一组相关的单元测试
- 利用
beforeEach()
和afterEach()
函数创建独立的单元测试函数 - 创建测试过的 Vue 组件中使用的任何库的模拟
- 在
beforeEach()
函数中呈现组件,并在单元测试函数中更新属性数据 - 利用
shallowMount()
而不是mount()
来集中测试单个组件
结论
本文提供了单元测试 Vue 组件的指南,重点关注:
- 为什么应该编写单元测试
- 你应该(和不应该)单元测试什么
- 如何编写单元测试
简单地说,当考虑测试什么时,重点测试输入和输出(实际结果),而不是底层的业务逻辑(结果是如何产生的)。记住这一点,花一两分钟时间再次回顾示例,记下测试的输入和输出。
同样,如果你有兴趣了解更多关于 Vue 的知识,可以看看我的课程:通过构建和部署 CRUD 应用来学习 Vue。
这是测试 Vue 应用的两部分系列的第一部分:
教程视图
描述
Vue 是一个开源的 JavaScript 框架,用于构建用户界面和单页应用程序(spa)。它采用了 React 和 Angular 的一些最佳实践。也就是说,与 React 和 Angular 相比,它要平易近人得多,因此初学者可以快速上手并运行。它也同样强大,因此它提供了创建现代前端应用程序所需的所有功能。
TestDriven.io 上的教程和文章讲述了如何测试 Vue 组件,以及如何使用 Vue、Flask 和 FastAPI 开发和部署单页应用程序(spa)。
如何用 Vue 和 FastAPI 设置一个基本的 CRUD 应用程序的分步演练。
测试 Vue 应用程序中的 Pinia 数据存储。
本文作为单元测试 Vue 组件的指南。
- 发帖者 郄佳朝 Medlin
- 最后更新于2021 年 11 月 3 日
结合烧瓶和 Vue 的三种不同方法。
如何将基于 Flask 的微服务(以及 Postgres 和 Vue.js)部署到 Kubernetes 集群的分步演练。
使用 Stripe、Vue.js 和 Flask 开发一个销售产品的 web 应用程序。
使用 Gitlab CI 将 Flask 和 Vue 支持的全栈 web 应用打包并部署到 Heroku。
如何用 Vue 和 Flask 设置一个基本的 CRUD 应用程序的分步演练。
比较的 Web 身份验证方法
在本文中,我们将从 Python web 开发人员的角度来看最常用的处理 web 身份验证的方法。
虽然代码示例和资源是为 Python 开发人员准备的,但是每种身份验证方法的实际描述适用于所有 web 开发人员。
身份验证与授权
身份认证是对试图访问受限系统的用户或设备的凭据进行验证的过程。同时,授权是验证用户或设备是否被允许在给定系统上执行某些任务的过程。
简而言之:
- 认证:你是谁?
- 授权:你能做什么?
认证先于授权。也就是说,在根据授权级别授予用户访问资源的权限之前,用户必须是有效的。认证用户最常见的方式是通过username
和password
。一旦通过认证,不同的角色如admin
、moderator
等。分配给他们,这将授予他们系统的特权。
接下来,让我们来看看认证用户的不同方法。
HTTP 基本身份验证
HTTP 协议中内置的基本身份验证是最基本的身份验证形式。有了它,登录凭据将在每个请求的请求头中发送:
`"Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" your-website.com`
用户名和密码不加密。相反,用户名和密码使用一个:
符号连接在一起,形成一个字符串:username:password
。然后,使用 base64 对该字符串进行编码。
`>>> import base64
>>>
>>> auth = "username:password"
>>> auth_bytes = auth.encode('ascii') # convert to bytes
>>> auth_bytes
b'username:password'
>>>
>>> encoded = base64.b64encode(auth_bytes) # base64 encode
>>> encoded
b'dXNlcm5hbWU6cGFzc3dvcmQ='
>>> base64.b64decode(encoded) # base64 decode
b'username:password'`
这个方法是无状态的,所以客户端必须为每个请求提供凭证。它适用于 API 调用以及不需要持久会话的简单身份验证工作流。
流动
- 未经身份验证的客户端请求受限资源
- HTTP 401 Unauthorized 返回一个值为
Basic
的头WWW-Authenticate
。 WWW-Authenticate: Basic
标题使浏览器显示用户名和密码提示- 输入您的凭证后,它们会在每个请求的标头中发送:
Authorization: Basic dcdvcmQ=
赞成的意见
- 因为没有太多的操作在进行,所以使用这种方法认证会更快。
- 容易实现。
- 所有主流浏览器都支持。
骗局
- Base64 不同于加密。这只是表示数据的另一种方式。base64 编码的字符串很容易被解码,因为它是以纯文本形式发送的。这种糟糕的安全特性需要多种类型的攻击。因此,HTTPS/SSL 是绝对必要的。
- 每个请求都必须发送凭据。
- 用户只能通过用无效凭据重写凭据来注销。
包装
密码
使用 Flask-HTTP 包可以在 Flask 中轻松完成基本的 HTTP 认证。
`from flask import Flask
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
auth = HTTPBasicAuth()
users = {
"username": generate_password_hash("password"),
}
@auth.verify_password
def verify_password(username, password):
if username in users and check_password_hash(users.get("username"), password):
return username
@app.route("/")
@auth.login_required
def index():
return f"You have successfully logged in, {auth.current_user()}"
if __name__ == "__main__":
app.run()`
资源
HTTP 摘要认证
HTTP 摘要式身份验证(或摘要式访问身份验证)是 HTTP 基本身份验证的一种更安全的形式。主要区别在于密码是以 MD5 散列形式发送的,而不是以明文形式,因此它比基本 Auth 更安全。
流动
- 未经身份验证的客户端请求受限资源
- 服务器生成一个名为 nonce 的随机值,并发回一个 HTTP 401 未授权状态,带有一个带有值为
Digest
的WWW-Authenticate
报头以及 nonce:WWW-Authenticate: Digest nonce="44f0437004157342f50f935906ad46fc"
WWW-Authenticate: Basic
标题使浏览器显示用户名和密码提示- 在输入您的凭证之后,密码被散列,然后在每个请求的报头中与随机数一起发送:
Authorization: Digest username="username", nonce="16e30069e45a7f47b4e2606aeeb7ab62", response="89549b93e13d438cd0946c6d93321c52"
- 有了用户名,服务器就获得了密码,将密码和随机数一起散列,然后验证散列是否相同
赞成的意见
- 比基本身份验证更安全,因为密码不是以纯文本形式发送的。
- 容易实现。
- 所有主流浏览器都支持。
骗局
- 每个请求都必须发送凭据。
- 用户只能通过使用无效凭据重写凭据来注销。
- 与基本 auth 相比,密码在服务器上不太安全,因为 bcrypt 不能使用。
- 易受中间人攻击。
包装
密码
Flask-HTTP 包也支持摘要 HTTP 认证。
`from flask import Flask
from flask_httpauth import HTTPDigestAuth
app = Flask(__name__)
app.config["SECRET_KEY"] = "change me"
auth = HTTPDigestAuth()
users = {
"username": "password"
}
@auth.get_password
def get_user(username):
if username in users:
return users.get(username)
@app.route("/")
@auth.login_required
def index():
return f"You have successfully logged in, {auth.current_user()}"
if __name__ == "__main__":
app.run()`
资源
基于会话的身份验证
使用基于会话的身份验证(或会话 cookie 身份验证或基于 cookie 的身份验证),用户的状态存储在服务器上。它不要求用户为每个请求提供用户名或密码。相反,在登录后,服务器会验证凭据。如果有效,它将生成一个会话,将其存储在会话存储中,然后将会话 id 发送回浏览器。浏览器将会话 ID 存储为 cookie,每当向服务器发出请求时,就会发送该 cookie。
基于会话的身份验证是有状态的。每当客户端请求服务器时,服务器必须在内存中定位会话,以便将会话 ID 绑定到相关用户。
流动
赞成的意见
- 更快的后续登录,因为不需要凭据。
- 改善了用户体验。
- 相当容易实现。许多框架(比如 Django)都提供了开箱即用的特性。
骗局
- 它是有状态的。服务器在服务器端跟踪每个会话。用于存储用户会话信息的会话存储需要跨多个服务共享,以实现身份验证。正因为如此,它不适合 RESTful 服务,因为 REST 是一种无状态协议。
- Cookies 会随每个请求一起发送,即使它不需要身份验证。
- 易受 CSRF 攻击。点击这里阅读更多关于 CSRF 以及如何预防它的信息。
包装
密码
Flask-Login 非常适合基于会话的认证。这个包负责登录、注销,并且可以在一段时间内记住用户。
`from flask import Flask, request
from flask_login import (
LoginManager,
UserMixin,
current_user,
login_required,
login_user,
)
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
app.config.update(
SECRET_KEY="change_this_key",
)
login_manager = LoginManager()
login_manager.init_app(app)
users = {
"username": generate_password_hash("password"),
}
class User(UserMixin):
...
@login_manager.user_loader
def user_loader(username: str):
if username in users:
user_model = User()
user_model.id = username
return user_model
return None
@app.route("/login", methods=["POST"])
def login_page():
data = request.get_json()
username = data.get("username")
password = data.get("password")
if username in users:
if check_password_hash(users.get(username), password):
user_model = User()
user_model.id = username
login_user(user_model)
else:
return "Wrong credentials"
return "logged in"
@app.route("/")
@login_required
def protected():
return f"Current user: {current_user.id}"
if __name__ == "__main__":
app.run()`
资源
基于令牌的认证
这种方法使用令牌而不是 cookies 来验证用户。用户使用有效凭据进行身份验证,服务器返回一个签名令牌。该令牌可用于后续请求。
最常用的令牌是一个 JSON Web 令牌 (JWT)。JWT 由三部分组成:
- 标头(包括令牌类型和使用的哈希算法)
- 有效负载(包括声明,即关于主题的陈述)
- 签名(用于验证消息没有被更改)
所有这三个都是 base64 编码的,使用一个.
连接起来并进行哈希处理。因为它们是编码的,任何人都可以解码和阅读信息。但是只有真实的用户才能产生有效的签名令牌。令牌使用签名进行身份验证,签名是用私钥签署的。
JSON Web Token (JWT)是一种紧凑的、URL 安全的方式,用于表示在双方之间传输的声明。JWT 中的声明被编码为 JSON 对象,该对象被用作 JSON Web 签名(JWS)结构的有效载荷,或者被用作 JSON Web 加密(JWE)结构的明文,从而能够对声明进行数字签名或使用消息认证码(MAC)进行完整性保护和/或加密。- IETF
令牌不需要保存在服务器端。他们可以通过他们的签名来验证。最近,由于 RESTful APIs 和单页面应用程序(spa)的兴起,令牌的采用有所增加。
流动
赞成的意见
- 它是无国籍的。服务器不需要存储令牌,因为它可以使用签名进行验证。这使得请求更快,因为不需要数据库查找。
- 适合微服务架构,其中多个服务需要身份验证。我们在每一端需要配置的只是如何处理令牌和令牌秘密。
骗局
- 根据令牌在客户端的保存方式,它可能会导致 XSS(通过本地存储)或 CSRF(通过 cookies)攻击。
- 无法删除令牌。它们只能过期。这意味着,如果令牌泄露,攻击者可以滥用它,直到到期。因此,将令牌到期时间设置得很短很重要,比如 15 分钟。
- 刷新令牌需要设置为在到期时自动颁发令牌。
- 删除令牌的一种方法是创建一个用于将令牌列入黑名单的数据库。这给微服务架构增加了额外的开销,并引入了状态。
包装
密码
烧瓶 JWTs 扩展包为处理 JWT 提供了许多可能性。
`from flask import Flask, request, jsonify
from flask_jwt_extended import (
JWTManager,
jwt_required,
create_access_token,
get_jwt_identity,
)
from werkzeug.security import check_password_hash, generate_password_hash
app = Flask(__name__)
app.config.update(
JWT_SECRET_KEY="please_change_this",
)
jwt = JWTManager(app)
users = {
"username": generate_password_hash("password"),
}
@app.route("/login", methods=["POST"])
def login_page():
username = request.json.get("username")
password = request.json.get("password")
if username in users:
if check_password_hash(users.get(username), password):
access_token = create_access_token(identity=username)
return jsonify(access_token=access_token), 200
return "Wrong credentials", 400
@app.route("/")
@jwt_required
def protected():
return jsonify(logged_in_as=get_jwt_identity()), 200
if __name__ == "__main__":
app.run()`
资源
一次性密码
一次性密码(OTP)通常用作身份验证的确认。OTP 是随机生成的代码,可用于验证用户是否是他们声称的那个人。它通常在利用双因素身份验证的应用程序的用户凭据得到验证后使用。
要使用 OTP,必须存在可信系统。这个可信系统可以是经过验证的电子邮件或手机号码。
现代 OTP 是无状态的。可以使用多种方法来验证它们。虽然有几种不同类型的动态口令,但基于时间的动态口令(TOTPs)无疑是最常见的类型。一旦生成,它们会在一段时间后过期。
因为您获得了额外的安全层,所以建议将 OTP 用于涉及高度敏感数据的应用程序,如网上银行和其他金融服务。
流动
实现 OTP 的传统方式是:
- 客户端发送用户名和密码
- 在凭证验证之后,服务器生成随机代码,将其存储在服务器端,并将代码发送到可信系统
- 用户在可信系统上获取代码,并将其输入回 web 应用程序
- 服务器根据存储的代码验证该代码,并相应地授权访问
TOTPs 如何工作:
- 客户端发送用户名和密码
- 在凭证验证之后,服务器使用随机生成的种子生成随机代码,将种子存储在服务器端,并将代码发送到可信系统
- 用户在可信系统上获取代码,并将其输入回 web 应用程序
- 服务器根据存储的种子验证代码,确保它没有过期,并相应地授予访问权限
像 Google Authenticator 、微软 Authenticator 、 FreeOTP 这样的 OTP 代理是如何工作的:
- 注册双因素身份验证(2FA)后,服务器会生成一个随机种子值,并以唯一 QR 码的形式将该种子发送给用户
- 用户使用他们的 2FA 应用程序扫描 QR 码来验证可信设备
- 每当需要 OTP 时,用户就在他们的设备上检查代码,并在 web 应用程序上输入代码
- 服务器验证代码并相应地授权访问
赞成的意见
- 增加一层额外的保护。
- 不存在被盗密码可用于也实现 OTP 的多个站点或服务的危险。
骗局
- 您需要存储用于生成 OTP 的种子。
- 如果您丢失了恢复代码,像 Google Authenticator 这样的 OTP 代理很难再次设置。
- 当可信设备不可用时(电池没电、网络错误等),问题就会出现。).因此,通常需要备份设备,这会增加额外的攻击媒介。
包装
密码
PyOTP 包提供基于时间和基于计数器的 OTP。
`from time import sleep
import pyotp
if __name__ == "__main__":
otp = pyotp.TOTP(pyotp.random_base32())
code = otp.now()
print(f"OTP generated: {code}")
print(f"Verify OTP: {otp.verify(code)}")
sleep(30)
print(f"Verify after 30s: {otp.verify(code)}")`
示例:
`OTP generated: 474771
Verify OTP: True
Verify after 30s: False`
资源
OAuth 和 OpenID
OAuth/OAuth2 和 OpenID 分别是授权和认证的流行形式。它们用于实现社交登录,这是一种形式的单点登录 (SSO),使用来自脸书、推特或谷歌等社交网络服务的现有信息登录第三方网站,而不是专门为该网站创建新的登录帐户。
当您需要高度安全的身份验证时,可以使用这种类型的身份验证和授权。其中一些提供商有足够的资源投资于身份验证本身。利用这种久经考验的身份验证系统最终可以使您的应用程序更加安全。
这种方法通常与基于会话的身份验证结合使用。
流动
您访问了一个要求您登录的网站。您导航到登录页面,看到一个名为“使用 Google 登录”的按钮。你点击按钮,它会把你带到谷歌登录页面。通过身份验证后,您会被重定向回自动让您登录的网站。这是一个使用 OpenID 进行身份验证的例子。它允许您使用现有帐户(通过 OpenID 提供者)进行身份验证,而无需创建新帐户。
最著名的 OpenID 提供商是谷歌、脸书、Twitter 和 GitHub。
登录后,您可以导航到网站内的下载服务,让您直接将大文件下载到 Google Drive。网站是如何访问你的谷歌硬盘的?这就是 OAuth 发挥作用的地方。您可以授予访问其他网站上的资源的权限。在这种情况下,对 Google Drive 进行写访问。
赞成的意见
- 提高安全性。
- 由于无需创建和记住用户名或密码,登录流程更加简单快捷。
- 在安全漏洞的情况下,不会发生第三方损害,因为认证是无密码的。
骗局
- 您的应用程序现在依赖于另一个应用程序,不受您的控制。如果 OpenID 系统关闭,用户将无法登录。
- 人们往往会忽略 OAuth 应用程序请求的权限。
- 在您配置的 OpenID 提供商上没有帐户的用户将无法访问您的应用程序。最好的方法是两者都实现——即用户名、密码和 OpenID——并让用户选择。
包装
希望实现社交登录?
想运行自己的 OAuth 或 OpenID 服务吗?
密码
你可以用 Flask-Dance 实现 GitHub social auth。
`from flask import Flask, url_for, redirect
from flask_dance.contrib.github import make_github_blueprint, github
app = Flask(__name__)
app.secret_key = "change me"
app.config["GITHUB_OAUTH_CLIENT_ID"] = "1aaf1bf583d5e425dc8b"
app.config["GITHUB_OAUTH_CLIENT_SECRET"] = "dee0c5bc7e0acfb71791b21ca459c008be992d7c"
github_blueprint = make_github_blueprint()
app.register_blueprint(github_blueprint, url_prefix="/login")
@app.route("/")
def index():
if not github.authorized:
return redirect(url_for("github.login"))
resp = github.get("/user")
assert resp.ok
return f"You have successfully logged in, {resp.json()['login']}"
if __name__ == "__main__":
app.run()`
资源
结论
在本文中,我们研究了许多不同的 web 身份验证方法,它们都有各自的优缺点。
你应该在什么时候使用它们?看情况。基本经验法则:
- 对于利用服务器端模板的 web 应用程序,通过用户名和密码进行基于会话的身份验证通常是最合适的。您也可以添加 OAuth 和 OpenID。
- 对于 RESTful APIs,基于令牌的认证是推荐的方法,因为它是无状态的。
- 如果您必须处理高度敏感的数据,您可能希望将 OTP 添加到您的授权流中。
最后,请记住,所示的示例只是触及了表面。生产使用需要进一步配置。
网页抓取教程
描述
Web 抓取是一个术语,用于描述使用程序或算法从 web 下载和提取结构化数据的过程。当您需要从没有公共 API 的网站中提取数据时,这是一项非常有用的技能。
TestDriven.io 上的教程和文章讲述了如何利用并行性和并发性来加速抓取大量数据的 web 抓取器。
将基于 Python 和 Selenium 的 web scraper 与 Selenium Grid 和 Docker Swarm 并行运行。
通过 concurrent.futures 模块使用多线程加速 Python web 抓取和爬行脚本。
什么是工具?
这篇文章解释了什么是 Werkzeug T1,以及 T2 Flask T3 如何使用它来实现核心的 HTTP 功能。在这个过程中,您将使用 Werkzeug 开发自己的 WSGI 兼容应用程序,以创建类似 Flask 的 web 框架!
本文假设您之前有使用 Flask 的经验。如果您有兴趣了解有关 Flask 的更多信息,请查看我关于如何构建、测试和部署 Flask 应用程序的课程:
烧瓶相关性
您可能已经注意到了,但是每次安装 Flask 时,您也会安装以下依赖项:
- 点击
- 其危险性
- 金佳
- MarkupSafe
- 工具
Flask 是所有这些东西的包装。
`$ pip install Flask
$ pip freeze
click==7.1.2
Flask==1.1.2
itsdangerous==1.1.0
Jinja2==2.11.3
MarkupSafe==1.1.1
Werkzeug==1.0.1 # !!!`
什么是工具?
Werkzeug 是一个库集合,可用于在 Python 中创建 WSGI (Web 服务器网关接口)兼容的 Web 应用程序。
WSGI (Web 服务器网关接口)服务器是 Python web 应用程序所必需的,因为 Web 服务器不能直接与 Python 通信。WSGI 是 web 服务器和基于 Python 的 web 应用程序之间的接口。
换句话说,Werkzeug 提供了一组实用程序来创建可以与 WSGI 服务器对话的 Python 应用程序,如 Gunicorn 。
想了解更多关于 WSGI 的知识吗?
Werkzeug 提供以下功能(哪个烧瓶使用):
- 请求处理
- 响应处理
- URL 路由
- 中间件
- HTTP 实用程序
- 异常处理
它还提供了一个具有热重装功能的基本开发服务器。
让我们深入一个使用 Werkzeug 构建 web 应用程序的例子。我们还将看看 Flask 如何实现类似的功能。
Hello World 应用程序
作为对 Werkzeug 的介绍,让我们首先使用 Werkzeug 提供的一些关键功能创建一个“Hello World”应用程序。
您可以在 GitLab 上找到本文讨论的项目的源代码:https://gitlab.com/patkennedy79/werkzeug_movie_app。
装置
首先创建一个新项目:
`$ mkdir werkzeug_movie_app
$ cd werkzeug_movie_app
$ python3 -m venv venv
$ source venv/bin/activate
(venv)$`
安装工具,Jinja,和redis py:
`(venv)$ pip install Werkzeug Jinja2 redis
(venv)$ pip freeze > requirements.txt`
Redis 将作为存储电影数据的数据存储解决方案。
应用
Werkzeug 是用于构建兼容 WSGI 的 web 应用程序的库集合。它没有提供像 Flask
这样的高级类来搭建完整的 web 应用程序。相反,您需要自己从 Werkzeug 的库中创建应用程序。
在项目的顶层文件夹中创建一个新的 app.py 文件:
`from werkzeug.wrappers import Request, Response
class MovieApp(object):
"""Implements a WSGI application for managing your favorite movies."""
def __init__(self):
pass
def dispatch_request(self, request):
"""Dispatches the request."""
return Response('Hello World!')
def wsgi_app(self, environ, start_response):
"""WSGI application that processes requests and returns responses."""
request = Request(environ)
response = self.dispatch_request(request)
return response(environ, start_response)
def __call__(self, environ, start_response):
"""The WSGI server calls this method as the WSGI application."""
return self.wsgi_app(environ, start_response)
def create_app():
"""Application factory function that returns an instance of MovieApp."""
app = MovieApp()
return app`
MovieApp
类实现了一个兼容 WSGI 的 web 应用程序,该应用程序处理来自不同用户的请求,并向用户返回响应。下面是这个类如何与 WSGI 服务器交互的流程:
当一个请求进来时,它在wsgi_app()
中被处理:
`def wsgi_app(self, environ, start_response):
"""WSGI application that processes requests and returns responses."""
request = Request(environ)
response = self.dispatch_request(request)
return response(environ, start_response)`
环境(environ
)在Request
类中被自动处理以创建一个request
对象。然后request
在dispatch_request()
进行处理。对于这个初始示例,dispatch_request()
返回一个响应“Hello World!”。然后从wsgi_app()
返回响应。
烧瓶比较:
MovieApp
是Flask
类的简化版。在
Flask
类中,wsgi_app()
是与 WSGI 服务器接口的实际 WSGI 应用程序。此外,dispatch_request()
和full_dispatch_request()
用于执行请求分派,将 URL 匹配到适用的视图函数并处理异常。
开发服务器
将以下代码添加到 app.py 的底部,以运行 Werkzeug 开发服务器:
`if __name__ == '__main__':
# Run the Werkzeug development server to serve the WSGI application (MovieApp)
from werkzeug.serving import run_simple
app = create_app()
run_simple('127.0.0.1', 5000, app, use_debugger=True, use_reloader=True)`
运行应用程序:
导航到 http://localhost:5000 查看“Hello World!”消息。
烧瓶比较:
在
Flask
类中,有一个等价的利用 Werkzeug 开发服务器的run()
方法。
用于服务静态文件的中间件
在 web 应用程序中,中间件是一个软件组件,可以添加到请求/响应处理管道中以执行特定的功能。
web 服务器/应用程序要执行的一个重要功能是提供静态文件(CSS、JavaScript 和图像文件)。Werkzeug 为这个功能提供了一个名为 SharedDataMiddleware
的中间件。
SharedDataMiddleware
非常适合与 Werkzeug 开发服务器一起工作来提供静态文件。
对于生产环境,您可能希望将 Werkzeug 开发服务器和
SharedDataMiddleware
切换为 web 服务器,如 Nginx 和 WSGI 服务器,如 Gunicorn。
要使用SharedDataMiddleware
,首先将一个名为“static”的新文件夹添加到带有“css”和“img”文件夹的项目中:
`├── app.py
├── requirements.txt
└── static
├── css
└── img`
在“static/img”文件夹内,添加来自https://git lab . com/patkennedy 79/werkzeug _ movie _ app/-/blob/main/static/img/Flask . png的烧瓶标志。保存为 flask.png 的。
*接下来,扩展应用程序工厂功能:
`def create_app():
"""Application factory function that returns an instance of MovieApp."""
app = MovieApp()
app.wsgi_app = SharedDataMiddleware(app.wsgi_app, {
'/static': os.path.join(os.path.dirname(__file__), 'static')
})
return app`
更新顶部的导入:
`import os
from werkzeug.middleware.shared_data import SharedDataMiddleware
from werkzeug.wrappers import Request, Response`
现在,当 Werkzeug 应用程序(app
)处理一个请求时,它将首先被路由到SharedDataMiddleware
以确定是否请求了一个静态文件:
如果请求静态文件,SharedDataMiddleware
将生成静态文件的响应。否则,请求将被传递到 Werkzeug 应用程序,在wsgi_app()
中进行处理。
要查看SharedDataMiddleware
的运行,运行服务器并导航到http://localhost:5000/static/img/flask . png查看 Flask 徽标。
要获得 Werkzeug 提供的中间件解决方案的完整列表,请查看中间件文档。
烧瓶比较:
烧瓶不使用
SharedDataMiddleware
。它采用不同的方法来提供静态文件。默认情况下,如果静态文件夹存在,Flask 会自动添加一个新的 URL 规则来提供静态文件。为了说明这个概念,在 Flask 应用程序的顶层项目中运行
flask routes
,您将看到:(venv)$ flask routes Endpoint Methods Rule ----------- ------- ----------------------- index GET / static GET /static/<path:filename>
模板
正如 Flask 项目中通常所做的那样,我们将使用 Jinja 作为我们应用程序的模板引擎。
首先向项目中添加一个名为“templates”的新文件夹:
`├── app.py
├── requirements.txt
├── static
│ ├── css
│ └── img
│ └── flask.png
└── templates`
为了利用 Jinja,扩展MovieApp
类的构造函数:
`def __init__(self):
"""Initializes the Jinja templating engine to render from the 'templates' folder."""
template_path = os.path.join(os.path.dirname(__file__), 'templates')
self.jinja_env = Environment(loader=FileSystemLoader(template_path),
autoescape=True)`
添加导入:
`from jinja2 import Environment, FileSystemLoader`
烧瓶比较:
Flask 也利用 Jinja
Environment
来创建模板引擎。
在MovieApp
类中,添加一个新的render_template()
方法:
`def render_template(self, template_name, **context):
"""Renders the specified template file using the Jinja templating engine."""
template = self.jinja_env.get_template(template_name)
return Response(template.render(context), mimetype='text/html')`
该方法将template_name
和任何变量传递给模板引擎(**context
)。然后它使用 Jinja 的render()
方法生成一个Response
。
烧瓶比较:
render_template()
函数看起来不眼熟吗?烧瓶风味是烧瓶中使用最多的功能之一。
要查看render_template()
的运行情况,请更新dispatch_request()
以呈现模板:
`def dispatch_request(self, request):
"""Dispatches the request."""
return self.render_template('base.html')`
对应用程序的所有请求现在都将呈现模板/base.html 模板。
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Werkzeug Movie App</title>
<!-- CSS file for styling the application -->
<link rel="stylesheet" href="/static/css/style.css" type="text/css">
</head>
<body>
<h1>Werkzeug Movie App</h1>
{% block body %}
{% endblock %}
</body>
</html>`
确保将此模板添加到“templates”文件夹中,并保存一份https://git lab . com/patkennedy 79/werkzeug _ movie _ app/-/blob/main/static/CSS/style . CSS到 static/css/style.css 的副本。
运行服务器。导航到 http://localhost:5000 。您现在应该看到:
按指定路线发送
路由意味着将 URL 与适当的视图功能相匹配。Werkzeug 提供了一个 Map
类,允许你使用 Rule
对象匹配 URL 来查看函数。
让我们在MovieApp
构造函数中创建Map
对象来说明这是如何工作的:
`def __init__(self):
"""Initializes the Jinja templating engine to render from the 'templates' folder."""
template_path = os.path.join(os.path.dirname(__file__), 'templates')
self.jinja_env = Environment(loader=FileSystemLoader(template_path),
autoescape=True)
self.url_map = Map([
Rule('/', endpoint='index'),
Rule('/movies', endpoint='movies'),
])`
不要忘记重要的一点:
`from werkzeug.routing import Map, Rule`
每个Rule
对象定义一个 URL 和一个视图函数(endpoint
),如果 URL 匹配,则调用该函数:
`self.url_map = Map([
Rule('/', endpoint='index'),
Rule('/movies', endpoint='movies'),
])`
比如请求主页('/')时,要调用index
视图函数。
烧瓶比较:
Flask 的一个令人惊奇的特性是
@route
装饰器,它被用来给一个视图函数分配一个 URL。这个装饰器为 Flask 应用程序更新了url_map
,类似于我们上面定义的手工编码的url_map
。
为了利用 URL 映射,需要更新dispatch_request()
:
`def dispatch_request(self, request):
"""Dispatches the request."""
adapter = self.url_map.bind_to_environ(request.environ)
try:
endpoint, values = adapter.match()
return getattr(self, endpoint)(request, **values)
except HTTPException as e:
return e`
现在,当一个请求进入dispatch_request()
时,url_map
将被用来尝试match()
一个条目的 URL。如果请求的 URL 包含在url_map
中,那么将调用适用的查看功能(endpoint
)。如果在url_map
中没有找到该 URL,则引发异常。
异常处理将很快被介绍!
添加导入:
`from werkzeug.exceptions import HTTPException`
我们已经在url_map
中指定了两个视图函数,所以现在让我们在MovieApp
类中创建它们:
`def index(self, request):
return self.render_template('base.html')
def movies(self, request):
return self.render_template('movies.html')`
虽然 templates/base.html 已在前一部分创建,但现在需要创建 templates/movies.html :
`{% extends "base.html" %}
{% block body %}
<div class="table-container">
<table>
<!-- Table Header -->
<thead>
<tr>
<th>Index</th>
<th>Movie Title</th>
</tr>
</thead>
<!-- Table Elements (Rows) -->
<tbody>
<tr>
<td>1</td>
<td>Knives Out</td>
</tr>
<tr>
<td>2</td>
<td>Pirates of the Caribbean</td>
</tr>
<tr>
<td>3</td>
<td>Inside Man</td>
</tr>
</tbody>
</table>
</div>
{% endblock %}`
这个模板文件利用模板继承来使用base.html作为父模板。它生成一个包含三部电影的表格。
http://localhost:5000 看起来应该是一样的:
但是,如果您导航到http://localhost:5000/movies,您现在会看到电影列表:
异常处理
尝试导航到http://localhost:5000/movies 2:
当在url_map
中没有找到 URL 时,返回的页面是默认的错误页面:
`def dispatch_request(self, request):
"""Dispatches the request."""
adapter = self.url_map.bind_to_environ(request.environ)
try:
endpoint, values = adapter.match()
return getattr(self, endpoint)(request, **values)
except HTTPException as e:
return e`
此外,您应该在控制台中看到以下内容:
`127.0.0.1 - - [07/Mar/2021 12:13:17] "GET /movies2 HTTP/1.1" 404 -`
让我们通过展开dispatch_request()
来创建一个定制的错误页面:
`def dispatch_request(self, request):
"""Dispatches the request."""
adapter = self.url_map.bind_to_environ(request.environ)
try:
endpoint, values = adapter.match()
return getattr(self, endpoint)(request, **values)
except NotFound:
return self.error_404()
except HTTPException as e:
return e`
更新导入:
`from werkzeug.exceptions import HTTPException, NotFound`
现在,当在url_map
中没有找到 URL 时,将通过调用error_404()
来处理它。在MovieApp
类中创建这个新方法:
`def error_404(self):
response = self.render_template("404.html")
response.status_code = 404
return response`
创建模板/404.html :
`{% extends "base.html" %}
{% block body %}
<div class="error-description">
<h2>Page Not Found (404)</h2>
<h4>What you were looking for is just not there!</h4>
<h4><a href="/">Werkzeug Movie App</a></h4>
</div>
{% endblock %}`
现在,当您导航到http://localhost:5000/movies 2时,您应该会看到一条友好的消息:
烧瓶比较:
当
Flask
类中的full_dispatch_request()
检测到异常时,会在handle_user_exceptions()
中优雅地处理。Flask 还允许为所有 HTTP 错误代码定制错误页面。
请求处理
在本节中,我们将向应用程序添加一个表单,允许用户输入他们最喜欢的电影。
雷迪斯
如前所述,我们将使用 Redis(一种内存中的数据结构存储)来保存电影,因为它的读/写速度快,易于设置。
安装并运行 Redis。
启动和运行 Redis 的最快方法是使用 Docker:
$ docker run --name some-redis -d -p 6379:6379 redis
要检查 Redis 容器是否正在运行:
要停止正在运行的 Redis 容器:
$ docker stop some-redis # Use name of Docker container
如果您不是 Docker 用户,请查看以下资源:
为了利用 Redis,首先更新MovieApp
构造函数来创建StrictRedis
的实例:
`def __init__(self, config): # Updated!!
"""Initializes the Jinja templating engine to render from the 'templates' folder,
defines the mapping of URLs to view methods, and initializes the Redis interface."""
template_path = os.path.join(os.path.dirname(__file__), 'templates')
self.jinja_env = Environment(loader=FileSystemLoader(template_path),
autoescape=True)
self.url_map = Map([
Rule('/', endpoint='index'),
Rule('/movies', endpoint='movies'),
])
self.redis = StrictRedis(config['redis_host'], config['redis_port'],
decode_responses=True) # New!!`
此外,构造函数(__init__()
)还有一个额外的参数(config
),用于创建StrictRedis
的实例。
导入:
`from redis import StrictRedis`
传递给构造函数的配置参数需要在应用程序工厂函数中指定:
`def create_app():
"""Application factory function that returns an instance of MovieApp."""
app = MovieApp({'redis_host': 'localhost', 'redis_port': 6379})
app.wsgi_app = SharedDataMiddleware(app.wsgi_app, {
'/static': os.path.join(os.path.dirname(__file__), 'static')
})
return app`
表单处理
为了允许用户向 Redis 存储器添加电影,我们需要在url_map
中添加一个新的查看功能:
`def __init__(self, config):
"""Initializes the Jinja templating engine to render from the 'templates' folder,
defines the mapping of URLs to view methods, and initializes the Redis interface."""
...
self.url_map = Map([
Rule('/', endpoint='index', methods=['GET']),
Rule('/movies', endpoint='movies', methods=['GET']),
Rule('/add', endpoint='add_movie', methods=['GET', 'POST']), # !!!
])
...`
url_map
中的Rule
条目已经扩展为指定每个 URL 允许的 HTTP 方法。此外,还添加了“/add”URL:
`Rule('/add', endpoint='add_movie', methods=['GET', 'POST']),`
如果用 GET 或 POST 方法请求“/add”URL,那么将调用add_movie()
视图函数。
接下来,我们需要在MovieApp
类中创建add_movie()
视图函数:
`def add_movie(self, request):
"""Adds a movie to the list of favorite movies."""
if request.method == 'POST':
movie_title = request.form['title']
self.redis.lpush('movies', movie_title)
return redirect('/movies')
return self.render_template('add_movie.html')`
导入:
`from werkzeug.utils import redirect`
如果对'/add '发出 GET 请求,那么add_movie()
将呈现模板/add_movie.html 文件。如果向“/add”发出 POST 请求,那么表单数据将存储在 Redis 存储器的movies
列表中,用户将被重定向到电影列表。
创建模板/add_movie.html 模板文件:
`{% extends "base.html" %}
{% block body %}
<div class="form-container">
<form method="post">
<div class="field">
<label for="movieTitle">Movie Title:</label>
<input type="text" id="movieTitle" name="title"/>
</div>
<div class="field">
<button type="submit">Submit</button>
</div>
</form>
</div>
{% endblock %}`
显示电影
因为我们现在将电影存储在 Redis 中,所以需要更新movie()
视图函数来读取 Redis 中的movies
列表:
`def movies(self, request):
"""Displays the list of favorite movies."""
movies = self.redis.lrange('movies', 0, -1)
return self.render_template('movies.html', movies=movies)`
电影列表将被传递到 templates/movies.html 模板文件,该文件需要更新以遍历该列表来创建电影表:
`{% extends "base.html" %}
{% block body %}
<div class="table-container">
<table>
<!-- Table Header -->
<thead>
<tr>
<th>Index</th>
<th>Movie Title</th>
</tr>
</thead>
<!-- Table Elements (Rows) -->
<tbody>
{% for movie in movies %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ movie }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}`
要查看表单处理的效果,首先导航到http://localhost:5000/add并添加一个新电影:
提交表单后,您应该会被自动重定向到电影列表(可能包括以前添加的电影):
就是这样!
为什么不用 Werkzeug 代替 Flask?
Werkzeug 提供了 Flask 中的许多关键功能,但是 Flask 增加了许多强大的特性,例如:
- 会议
- 应用程序和请求上下文
- 蓝图
- 请求回调函数
- 公用事业:
@route
装饰工url_for()
功能
- CLI 命令
- 异常处理
- 测试客户端
- 烧瓶外壳
- 记录
- 信号
- 扩展ˌ扩张
与任何 web 框架一样——不要重新发明轮子!Flask 是 web 开发的一个更好的选择(与 Werkzeug 相比),因为它有丰富的特性集和大量的扩展。
结论
本文通过展示如何使用 Werkzeug 构建一个简单的 web 应用程序,概述了 Werkzeug,它是 Flask 的关键组件之一。虽然理解 Flask 中底层库的工作方式很重要,但是使用 Werkzeug 创建 web 应用程序的复杂性应该说明使用 Flask 开发 web 应用程序是多么容易!
此外,如果您有兴趣了解如何测试 Werkzeug 应用程序,请查看 Werkzeug 电影应用程序的测试:https://git lab . com/patkennedy 79/Werkzeug _ Movie _ App/-/tree/main/tests。
如果你想了解更多关于 Flask 的知识,一定要看看我的课程- 用 Python 和 Flask 开发 Web 应用。
干杯!*