Skip to content

Latest commit

 

History

History
1808 lines (1452 loc) · 57.1 KB

demo.md

File metadata and controls

1808 lines (1452 loc) · 57.1 KB

Demo of BDD/TDD/CI on Django

前言

本來想用網站帳號註冊與登入來示範 TDD/BDD,但是

  • 帳號檢查的邏輯太簡單,很難示範 TDD
  • Behave-django 做得太好了,Model 根本不需要 mock

要找出一個範例會複雜到需要拆成 features, steps, interfaces, implementation of SUT, 還有要切割依賴的 DOC,同時又必須簡單到範例可以塞進一張投影片。幾經考量,轉而想使用解析字串的計算機(類似工程用計算機)做範例。

計算機須滿足 四則運算規則

Source code

demo/ 不包含開發過程,只有最終完整的程式碼。

如果想玩CI (要git commit),請複製 demo/ 目錄到其他位置。因為上層的 learn-test/ 本身就有一個 git repository,裡面再放一個 git repository,後果會很可怕。

設定環境

vagrant@debian:~$ cd myWorkspace/venv/
$ source venv/bin/activate
(venv) vagrant@debian:~/myWorkspace$
  • 以下範例省略提示符號$前所有文字

安裝 Djando 專案

$ django-admin startproject demo
$ tree demo/
demo/
├── demo
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py
$ cd demo

建立 Calculator App

$ python manage.py startapp calc
$ tree calc
calc
├── admin.py
├── apps.py
├── __init__.py
├── migrations
│   └── __init__.py
├── models.py
├── tests.py
└── views.py

新增 Calculator App

修改 demo/settings.py

INSTALLED_APPS = [
    'behave_django',
    ...
]

藉由 BDD 引導,定義特徵與步驟

一開始什麼都沒有

先什麼都不做,跑一下 behave 看看發生什麼事情

第一次執行 behave

$ python manage.py behave
Creating test database for alias 'default'...
ConfigError: No steps directory in "/home/vagrant/myWorkspace/demo/features"
Destroying test database for alias 'default'...
  • 溫馨提示: 缺少 features/steps 目錄

建立 features/steps 目錄

$ mkdir -p features/steps

第二次執行 behave

$ python manage.py behave
Creating test database for alias 'default'...
ConfigError: No feature files in "/home/vagrant/myWorkspace/demo/features"
Destroying test database for alias 'default'...
  • 溫馨提示: 還沒有定義特徵 (features)

建立 calc 的特徵描述檔

產生檔案 features/calc.feature:

Feature: Web calculator

    As a student
    In order to finish my homework
    I want to do arithmatical operations

    Scenario: add two numbers
        Given I enter "1+1"
         When I press "=" button
         Then I get the answer "2"

第三次執行 behave

$ python manage.py behave --dry-run

Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario: add two numbers   # features/calc.feature:9
    Given I enter "3+2"       # None
    When I press "=" button   # None
    Then I get the answer "5" # None

0 features passed, 0 failed, 0 skipped, 1 untested
0 scenarios passed, 0 failed, 0 skipped, 1 untested
0 steps passed, 0 failed, 0 skipped, 3 undefined
Took 0m0.000s

You can implement step definitions for undefined steps with these snippets:

@given(u'I enter "3+2"')
def step_impl(context):
    raise NotImplementedError(u'STEP: Given I enter "3+2"')

@when(u'I press "=" button')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I press "=" button')

@then(u'I get the answer "5"')
def step_impl(context):
    raise NotImplementedError(u'STEP: Then I get the answer "5"')
  • 溫馨提示: 還沒有定義步驟 (steps),這些 snippets 可以拿去用

建立 calc 的步驟定義檔

產生檔案 features/steps/calc.py:

@given(u'I enter "3+2"')
def step_impl(context):
    raise NotImplementedError(u'STEP: Given I enter "1+1"')

@when(u'I press "=" button')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I press "=" button')

@then(u'I get the answer "5"')
def step_impl(context):
    raise NotImplementedError(u'STEP: Then I get the answer "2"')

第四次執行 behave

$ python manage.py behave --dry-run

Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario: add two numbers   # features/calc.feature:9
    Given I enter "3+2"       # None
    When I press "=" button   # None
    Then I get the answer "5" # None

0 features passed, 0 failed, 0 skipped, 1 untested
0 scenarios passed, 0 failed, 0 skipped, 1 untested
0 steps passed, 0 failed, 0 skipped, 0 undefined, 3 untested
Took 0m0.000s
  • 溫馨提示: 一個 feature、一個 scenario、三個 steps 沒有測試

再增加一個 scenario

修改 features/calc.feature,增加以下程式片段:

    Scenario: subtract two numbers
        Given I enter "3-2"
         When I press "=" button
         Then I get the answer "1"

第五次執行 behave

$ python manage.py behave --dry-run

Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario: add two numbers   # features/calc.feature:9
    Given I enter "3+2"       # None
    When I press "=" button   # None
    Then I get the answer "5" # None

  Scenario: subtract two numbers  # features/calc.feature:14
    Given I enter "3-2"           # None
    When I press "=" button       # None
    Then I get the answer "1"     # None

0 features passed, 0 failed, 0 skipped, 1 untested
0 scenarios passed, 0 failed, 0 skipped, 2 untested
0 steps passed, 0 failed, 0 skipped, 2 undefined, 4 untested
Took 0m0.000s

You can implement step definitions for undefined steps with these snippets:

@given(u'I enter "3-2"')
def step_impl(context):
    raise NotImplementedError(u'STEP: Given I enter "3-2"')

@then(u'I get the answer "1"')
def step_impl(context):
    raise NotImplementedError(u'STEP: Then I get the answer "1"')
  • 溫馨提示: 有些 features, scenarios, steps 沒有測試;有 steps 沒定義,可使用提供的 snippets

修改 calc 的步驟定義檔

修改檔案 features/steps/calc.py,增加以下程式片段:

@given(u'I enter "3-2"')
def step_impl(context):
    raise NotImplementedError(u'STEP: Given I enter "3-2"')

@then(u'I get the answer "1"')
def step_impl(context):
    raise NotImplementedError(u'STEP: Then I get the answer "1"')

第六次執行 behave

$ python manage.py behave --dry-run

Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario: add two numbers   # features/calc.feature:9
    Given I enter "3+2"       # None
    When I press "=" button   # None
    Then I get the answer "5" # None

  Scenario: subtract two numbers  # features/calc.feature:14
    Given I enter "3-2"           # None
    When I press "=" button       # None
    Then I get the answer "1"     # None

0 features passed, 0 failed, 0 skipped, 1 untested
0 scenarios passed, 0 failed, 0 skipped, 2 untested
0 steps passed, 0 failed, 0 skipped, 0 undefined, 6 untested
Took 0m0.000s
  • 溫馨提示: 有些 features, scenarios, steps 沒有測試

重構步驟定義檔 - 使用變數

修改檔案 features/steps/calc.py

@given(u'I enter {expr}')
def step_impl(context, expr):
    raise NotImplementedError(u'STEP: Given I enter {expr}')

@when(u'I press "=" button')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I press "=" button')

@then(u'I get the answer {answer}')
def step_impl(context, answer):
    raise NotImplementedError(u'STEP: Then I get the answer {answer}')

第七次執行 behave

$ python manage.py behave --dry-run

Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario: add two numbers   # features/calc.feature:9
    Given I enter "3+2"       # None
    When I press "=" button   # None
    Then I get the answer "5" # None

  Scenario: subtract two numbers  # features/calc.feature:14
    Given I enter "3-2"           # None
    When I press "=" button       # None
    Then I get the answer "1"     # None

0 features passed, 0 failed, 0 skipped, 1 untested
0 scenarios passed, 0 failed, 0 skipped, 2 untested
0 steps passed, 0 failed, 0 skipped, 0 undefined, 6 untested
Took 0m0.000s
  • 步驟檔使用變數,定義一個 step 能適用多個 scenarios

重構特徵描述檔 - 合併場景

修改 features/calc.feature,使用 Scenario Outline 合併加法、減法場景,並增加更多場景與錯誤處理

    Scenario Outline: do simple operations
        Given I enter <expression>
         When I press "=" button
         Then I get the answer <answer>

        Examples:
            | expression    | answer        |
            | 3 + 2         | 5             |
            | 3 - 2         | 1             |
            | 3 * 2         | 6             |
            | 3 / 2         | 1.5           |
            | 3 +-*/        | Invalid Input |
            | hello world   | Invalid Input |

第八次執行 behave

$ python manage.py behave --dry-run

Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario Outline: do simple operations -- @1.1   # features/calc.feature:16
    Given I enter 3 + 2                            # None
    When I press "=" button                        # None
    Then I get the answer 5                        # None

  Scenario Outline: do simple operations -- @1.2   # features/calc.feature:17
    Given I enter 3 - 2                            # None
    When I press "=" button                        # None
    Then I get the answer 1                        # None

  Scenario Outline: do simple operations -- @1.3   # features/calc.feature:18
    Given I enter 3 * 2                            # None
    When I press "=" button                        # None
    Then I get the answer 6                        # None

  Scenario Outline: do simple operations -- @1.4   # features/calc.feature:19
    Given I enter 3 / 2                            # None
    When I press "=" button                        # None
    Then I get the answer 1.5                      # None

  Scenario Outline: do simple operations -- @1.5   # features/calc.feature:20
    Given I enter 3 +-*/ 2                         # None
    When I press "=" button                        # None
    Then I get the answer Invalid Input            # None

  Scenario Outline: do simple operations -- @1.6   # features/calc.feature:21
    Given I enter hello world                      # None
    When I press "=" button                        # None
    Then I get the answer Invalid Input            # None

0 features passed, 0 failed, 0 skipped, 1 untested
0 scenarios passed, 0 failed, 0 skipped, 6 untested
0 steps passed, 0 failed, 0 skipped, 0 undefined, 18 untested
Took 0m0.000s
  • 溫馨提示: 更多 features, scenarios, steps 沒有測試

增加更多特徵

修改 features/calc.feature,使特徵滿足 加法/乘法交換律加法/乘法結合律、、乘法分配律

    Scenario Outline: satisfy commutative property
         When I enter <expression1> first
          And I enter <expression2> again
         Then I get the same answer

        Examples:
            | expression1   | expression2   |
            | 3 + 4         | 4 + 3         |
            | 2 * 5         | 5 * 2         |

    Scenario Outline: satisfy associative property
         When I enter <expression1> first
          And I enter <expression2> again
         Then I get the same answer

        Examples:
            | expression1   | expression2   |
            | (2 + 3) + 4   | 2 + (3 + 4)   |
            | 2 * (3 * 4)   | (2 * 3) * 4   |

    Scenario Outline: satisfy distributive property
         When I enter <expression1> first
          And I enter <expression2> again
         Then I get the same answer

        Examples:
            | expression1   | expression2   |
            | 2 * (1 + 3)   | (2*1) + (2*3) |
            | (1 + 3) * 2   | (1*2) + (3*2) |

第九次執行 behave

$ python manage.py behave --dry-run

Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario Outline: do simple operations -- @1.1   # features/calc.feature:16
    Given I enter 3 + 2                            # None
    When I press "=" button                        # None
    Then I get the answer 5                        # None

  ...(略)

0 features passed, 0 failed, 0 skipped, 1 untested
0 scenarios passed, 0 failed, 0 skipped, 11 untested
0 steps passed, 0 failed, 0 skipped, 15 undefined, 18 untested
Took 0m0.000s

You can implement step definitions for undefined steps with these snippets:

@when(u'I enter 3 + 4 first')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter 3 + 4 first')

@when(u'I enter 4 + 3 again')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter 4 + 3 again')

@then(u'I get the same answer')
def step_impl(context):
    raise NotImplementedError(u'STEP: Then I get the same answer')

@when(u'I enter 2 * 5 first')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter 2 * 5 first')

@when(u'I enter 5 * 2 again')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter 5 * 2 again')

@when(u'I enter (2 + 3) + 4 first')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter (2 + 3) + 4 first')

@when(u'I enter 2 + (3 + 4) again')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter 2 + (3 + 4) again')

@when(u'I enter 2 * (3 * 4) first')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter 2 * (3 * 4) first')

@when(u'I enter (2 * 3) * 4 again')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter (2 * 3) * 4 again')

@when(u'I enter 2 * (1 + 3) first')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter 2 * (1 + 3) first')

@when(u'I enter (2*1) + (2*3) again')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter (2*1) + (2*3) again')

@when(u'I enter (1 + 3) * 2 first')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter (1 + 3) * 2 first')

@when(u'I enter (1*2) + (3*2) again')
def step_impl(context):
    raise NotImplementedError(u'STEP: When I enter (1*2) + (3*2) again')

增加步驟

@when(u'I enter {expr1} first')
def step_impl(context, expr1):
    raise NotImplementedError(u'STEP: When I enter {expr} first')

@when(u'I enter {expr2} again')
def step_impl(context, expr2):
    raise NotImplementedError(u'STEP: When I enter {expr2} again')

@then(u'I get the same answer')
def step_impl(context):
    raise NotImplementedError(u'STEP: Then I get the same answer')

第十次執行 behave,這次不用 --dry-run 參數

$ python manage.py behave

Creating test database for alias 'default'...
Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario Outline: do simple operations -- @1.1   # features/calc.feature:16
    Given I enter 3 + 2                            # features/steps/calc.py:1 0.000s
      Traceback (most recent call last):
        File "/home/vagrant/myWorkspace/venv/lib/python3.5/site-packages/behave/model.py", line 1456, in run
          match.run(runner.context)
        File "/home/vagrant/myWorkspace/venv/lib/python3.5/site-packages/behave/model.py", line 1903, in run
          self.func(context, *args, **kwargs)
        File "/home/vagrant/.pyenv/versions/3.5.1/lib/python3.5/contextlib.py", line 77, in __exit__
          self.gen.throw(type, value, traceback)
        File "/home/vagrant/myWorkspace/venv/lib/python3.5/site-packages/behave/runner.py", line 162, in user_mode
          yield
        File "/home/vagrant/myWorkspace/venv/lib/python3.5/site-packages/behave/model.py", line 1903, in run
          self.func(context, *args, **kwargs)
        File "features/steps/calc.py", line 3, in step_impl
          raise NotImplementedError(u'STEP: Given I enter {expr}')
      NotImplementedError: STEP: Given I enter {expr}

    When I press "=" button                        # None
    Then I get the answer 5                        # None

...(略)

Failing scenarios:
  features/calc.feature:16  do simple operations -- @1.1
  features/calc.feature:17  do simple operations -- @1.2
  features/calc.feature:18  do simple operations -- @1.3
  features/calc.feature:19  do simple operations -- @1.4
  features/calc.feature:20  do simple operations -- @1.5
  features/calc.feature:21  do simple operations -- @1.6
  features/calc.feature:30  satisfy commutative property -- @1.1
  features/calc.feature:31  satisfy commutative property -- @1.2
  features/calc.feature:40  satisfy associative property -- @1.1
  features/calc.feature:41  satisfy associative property -- @1.2
  features/calc.feature:50  satisfy distributive property -- @1.1

0 features passed, 1 failed, 0 skipped
0 scenarios passed, 11 failed, 0 skipped
0 steps passed, 11 failed, 22 skipped, 0 undefined
Took 0m0.003s
Destroying test database for alias 'default'...
  • 溫馨提示: 一堆 features, scenarios, steps 失敗

重構特徵描述檔 - 移除 NotImplementedError

from cala.calculator import Calculator

@given(u'I enter {expr}')
def step_impl(context, expr):
    context.expr = expr

@when(u'I press "=" button')
def step_impl(context):
    calc = Calculator()
    context.answer = calc.evalString(context.expr)

@then(u'I get the answer {answer}')
def step_impl(context, answer):
    try:
        ans = float(answer)
    except ValueError:
        ans = answer

    assert context.answer == ans

@when(u'I enter {expr1} first')
def step_impl(context, expr1):
    calc = Calculator()
    context.answer1 = calc.evalString(expr1)

@when(u'I enter {expr2} again')
def step_impl(context, expr2):
    calc = Calculator()
    context.answer2 = calc.evalString(expr2)

@then(u'I get the same answer')
def step_impl(context):
    assert context.answer1 == context.answer2

第十一次執行 behave

$ python manage.py behave

Creating test database for alias 'default'...
Exception ImportError: No module named 'calc.calculator'; 'calc' is not a package
Traceback (most recent call last):
  ...(略)
  File "/home/vagrant/myWorkspace/demo/features/steps/calc.py", line 1, in <module>
    from calc.calculator import Calculator
ImportError: No module named 'calc.calculator'; 'calc' is not a package
  • 溫馨提示: No module named 'calc.calculator'; 'calc' is not a package

修改專案設定

修改 demo/settings.py,增加 calc App

INSTALLED_APPS = [
    ...
    'calc',
]

第十二次執行 behave

$ python manage.py behave

Creating test database for alias 'default'...
Exception ImportError: No module named 'calc.calculator'
Traceback (most recent call last):
  ...(略)
  File "features/steps/calc.py", line 1, in <module>
    from calc.calculator import Calculator
ImportError: No module named 'calc.calculator'
  • 溫馨提示: No module named 'calc.calculator'

增加模組 calc

$ touch calc/calculator.py

第十三次執行 behave

$ python manage.py behave

Creating test database for alias 'default'...
Exception ImportError: cannot import name 'Calculator'
Traceback (most recent call last):
  ...(略)
  File "features/steps/calc.py", line 1, in <module>
    from calc.calculator import Calculator
ImportError: cannot import name 'Calculator'
  • 溫馨提示: cannot import name 'Calculator'

增加 Calculator 類別

修改 calc/calculator.py,增加 Calculator 類別,並提供 evalString 方法

class Calculator:

    def evalString(self, string):
        return 0

第十四次執行 behave

$ python manage.py behave

Creating test database for alias 'default'...
Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario Outline: do simple operations -- @1.1   # features/calc.feature:16
    Given I enter 3 + 2                            # features/steps/calc.py:3 0.000s
    When I press "=" button                        # features/steps/calc.py:7 0.000s
    Then I get the answer 5                        # features/steps/calc.py:12 0.000s
      Traceback (most recent call last):
        File "/home/vagrant/myWorkspace/venv/lib/python3.5/site-packages/behave/model.py", line 1456, in run
          match.run(runner.context)
        File "/home/vagrant/myWorkspace/venv/lib/python3.5/site-packages/behave/model.py", line 1903, in run
          self.func(context, *args, **kwargs)
        File "features/steps/calc.py", line 19, in step_impl
          assert context.answer == ans
      AssertionError

...(略)

Failing scenarios:
  features/calc.feature:16  do simple operations -- @1.1
  features/calc.feature:17  do simple operations -- @1.2
  features/calc.feature:18  do simple operations -- @1.3
  features/calc.feature:19  do simple operations -- @1.4
  features/calc.feature:20  do simple operations -- @1.5
  features/calc.feature:21  do simple operations -- @1.6

0 features passed, 1 failed, 0 skipped
5 scenarios passed, 6 failed, 0 skipped
27 steps passed, 6 failed, 0 skipped, 0 undefined
Took 0m0.005s
Destroying test database for alias 'default'...
  • 溫馨提示: 有些成功、有些失敗 (因為還沒實作功能)
  • 可以開始實作 Calculator

藉由 TDD 引導,實作底層功能

新增第一個測試案例

修改 calc/tests.py

from django.test import TestCase
from calc.calculator import Calculator

# Create your tests here.
class TestCalculator(TestCase):

    def setUp(self):
        self.calc = Calculator()

    def test_evalString(self):
        evalString = self.calc.evalString
        self.assertEqual(evalString('0'), 0)

執行 unittest

$ python manage.py test -v2
Creating test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
Operations to perform:
  Synchronize unmigrated apps: behave_django, messages, django_jenkins, staticfiles
  Apply all migrations: sessions, admin, auth, contenttypes
Synchronizing apps without migrations:
  Creating tables...
    Running deferred SQL...
Running migrations:
  ...(略)
test_evalString (calc.tests.TestCalculator) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.002s

OK
Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
  • 溫馨提示: 剛剛最簡單的測試 test_evalString 通過

接下來把通過測試的程式碼與測試程式碼放上 git repository

程式碼版本控制

初始化 git repository

$ git init

新增 .gitignore 檔案,避免某些檔案加入 git repository

__pycache__
db.sqlite3
.python-version
*.pyc
.*.swp
reports/

產生套件相依訊息

$ pip freeze > requirements.txt

修改 demo/settings.py,增加 Jenkins 相關設定

INSTALLED_APPS = [
    'behave_django',
    'django_jenkins',
    ...
]

# Jenkins settings
PROJECT_APPS = [
    'calc',
]

JENKINS_TASKS = (
    'django_jenkins.tasks.run_pylint',
)

第一次提交

$ git add .
$ git commit -m "init project"

設定 Jenkins Server (CI 相關設定)

請參考細節

Jenkins "Publish JUnit test result report" 有個 tricky 的特性,如果沒有任何 unit test,執行結果就視為失敗,所以等到有第一個測試案例後才建立 Jenkins project

再多一點點測試

修改 calc/tests.py

    def test_evalString(self):
        evalString = self.calc.evalString
        self.assertEqual(evalString('0'), 0)
        self.assertEqual(evalString('1'), 1)

執行 unittest

$ python manage.py test -v2
...(略)

test_evalString (calc.tests.TestCalculator) ... FAIL

======================================================================
FAIL: test_evalString (calc.tests.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/vagrant/myWorkspace/demo/calc/tests.py", line 13, in test_evalString
    self.assertEqual(evalString('1'), 1)
AssertionError: 0 != 1

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=1)
  • 溫馨提示: test_evalString 測試失敗,快去修改 Calculator

插播 pyparsing

接下來會使用 pyparsing 來解析輸入字串,把字串變成“數字”與“運算符號”,然後根據運算符號執行正確的計算。

例如,輸入 3+2,解析字串後得到 exprStack=['3', '2', '+'],從 exprStack 取出 (pop) 元素 +,因為 + 是運算符號,要再取出兩個運算元 23 (依照取出順序),然後呼叫 + 符號對應的函數 add(3, 2),最後得答案 5

細節請參考 pyparsing 說明範例

修改 Calculator - 增加解析字串能力

修改 calc/tests.py,增加 parseString 測試

    def test_parseString(self):
        parseString = self.calc.parseString
        self.assertEqual(parseString('0'), ['0'])
        self.assertEqual(parseString('1'), ['1'])

執行 unittest

$ python manage.py test
...(略)
======================================================================
ERROR: test_parseString (calc.tests.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/vagrant/myWorkspace/demo/calc/tests.py", line 11, in test_parseString
    parseString = self.calc.parseString
AttributeError: 'Calculator' object has no attribute 'parseString'
  • 溫馨提示: 'Calculator' object has no attribute 'parseString'

修改 calc/calculator.py,增加 parseString 方法滿足測試

from pyparsing import nums, Word, StringEnd

"""
integer :: '0'...'9'*
expr    :: integer
"""

class Calculator:

    def __init__(self):
        self.exprStack = []

        integer = Word(nums)
        self.expr = integer + StringEnd()

    def parseString(self, string):
        self.exprStack = []
        return self.expr.parseString(string).asList()

    def evalString(self, string):
        return 0
  • 增加 parseString 解析功能

執行 unittest,測試 parseString 方法

$ python manage.py test -v2
...(略)
test_evalString (calc.tests.TestCalculator) ... FAIL
test_parseString (calc.tests.TestCalculator) ... ok
  • 溫馨提示: test_parseString 測試通過,繼續努力

修改 Calculator - 增加解析 exprStack 能力

修改 calc/tests.py,增加 evalStack 測試

    def test_evalStack(self):
        evalStack = self.calc.evalStack
        self.assertEqual(evalStack(['0']), 0)
        self.assertEqual(evalStack(['1']), 1)

執行 unittest

$ python manage.py test
...(略)
======================================================================
ERROR: test_evalStack (calc.tests.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/vagrant/myWorkspace/demo/calc/tests.py", line 16, in test_evalStack
    evalStack = self.calc.evalStack
AttributeError: 'Calculator' object has no attribute 'evalStack'
  • 溫馨提示: 'Calculator' object has no attribute 'evalStack'

修改 calc/calculator.py,增加 evalStack 方法滿足測試

class Calculator:

    def __init__(self):
        self.exprStack = []

        def pushStack(s, l, t):
            self.exprStack.append(t[0])

        integer = Word(nums).addParseAction(pushStack)
        self.expr = integer + StringEnd()

    ...()
    
    def evalStack(self, stack):
        op = stack.pop()
        return float(op)

執行 unittest,測試 evalStack 方法

$ python manage.py test -v2
...(略)
test_evalStack (calc.tests.TestCalculator) ... ok
test_evalString (calc.tests.TestCalculator) ... FAIL
test_parseString (calc.tests.TestCalculator) ... ok
  • 溫馨提示: test_evalStack 測試通過,快讓 test_evalString 通過測試吧

修改 Calculator - 完善解析字串能力

這次不修改 calc/tests.py,沿用裡面的測試

修改 calc/calculator.py,完善 parseString 方法滿足測試

class Calculator:

    def __init__(self):
        ...()

    def parseString(self, string):
        ...()

    def evalStack(self, stack):
        ...()
    
    def evalString(self, string):
        self.parseString(string)
        return self.evalStack(self.exprStack)

執行 unittest,測試 evalString 方法

$ python manage.py test -v2
...(略)
test_evalStack (calc.tests.TestCalculator) ... ok
test_evalString (calc.tests.TestCalculator) ... ok
test_parseString (calc.tests.TestCalculator) ... ok
  • 溫馨提示: test_evalStack, test_evalString, test_parseString 測試通過,先 git commit 吧
$ git add .
$ git commit -m "test evalStack, evalString, parseString: ok"

修改 Calculator - 增加錯誤處理

修改 calc/tests.py,增加輸入錯誤測試

    def test_invalid_input(self):
        evalString = self.calc.evalString
        self.assertEqual(evalString('hello world'), 'Invalid Input')

執行 unittest,測試 evalString 方法

$ python manage.py test
..E.
======================================================================
ERROR: test_invalid_input (calc.tests.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  ...(略)
  File "/home/vagrant/myWorkspace/venv/lib/python3.5/site-packages/pyparsing.py", line 1936, in parseImpl
    raise ParseException(instring, loc, self.errmsg, self)
pyparsing.ParseException: Expected W:(0123...) (at char 0), (line:1, col:1)
  • 溫馨提示: 發生 ParseException,快去處理

修改 calc/calculator.py,完善 parseString 方法滿足測試

from pyparsing import nums, Word, StringEnd, ParseException

class Calculator:

    ...()
    
    def evalString(self, string):
        try:
            self.parseString(string)
            return self.evalStack(self.exprStack)
        except ParseException:
            return 'Invalid Input'

執行 unittest,測試 evalString 方法

$ python manage.py test
Creating test database for alias 'default'...
....
----------------------------------------------------------------------
Ran 4 tests in 0.005s

OK
Destroying test database for alias 'default'...
  • 溫馨提示: 測試全部通過,先 git commit 吧
$ git add .
$ git commit -m "handle ParseException"

修改 Calculator - 處理簡單數學運算

修改 calc/tests.py,增加 parseString 測試項目

    def test_parseString(self):
        parseString = self.calc.parseString
        self.assertEqual(parseString('0'), ['0'])
        self.assertEqual(parseString('1'), ['1'])
        self.assertEqual(parseString('3+2'), ['3', '2', '+'])

執行 unittest,測試 evalString 方法

$ python manage.py test -v2
...(略)
======================================================================
ERROR: test_parseString (calc.tests.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/vagrant/myWorkspace/demo/calc/tests.py", line 14, in test_parseString
    self.assertEqual(parseString('3+2'), ['3', '2', '+'])
  ...(略)
    raise ParseException(instring, loc, self.errmsg, self)
pyparsing.ParseException: Expected end of text (at char 1), (line:1, col:2)
  • 溫馨提示: 只能解析輸入僅有一個整數的字串,像是 3+2 會產生 ParseException 例外

修改 calc/calculator.py,完善 parseString 方法滿足測試

from pyparsing import nums, Word, StringEnd, ParseException, Literal, ZeroOrMore

"""
integer :: '0'...'9'*
op      :: '+' | '-' | '*' | '/'
expr    :: integer [op integer]*
"""

class Calculator:

    def __init__(self):
        self.exprStack = []
        def pushStack(s, l, t):
            self.exprStack.append(t[0])

        integer = Word(nums).addParseAction(pushStack)
        op = Literal('+') | Literal('-') | Literal('*') | Literal('/')
        expr = integer + ZeroOrMore((op + integer).addParseAction(pushStack))

        self.expr = expr + StringEnd()

    def parseString(self, string):
        self.exprStack = []
        self.expr.parseString(string)
        return self.exprStack
    
    ...()
  • expr :: integer [op integer]* 可以處理多個運算符號
  • 修改 parseString 方法,回傳 exprStack

執行 unittest,測試 parseString 方法

$ python manage.py test -v2
...(略)
test_evalStack (calc.tests.TestCalculator) ... ok
test_evalString (calc.tests.TestCalculator) ... ok
test_invalid_input (calc.tests.TestCalculator) ... ok
test_parseString (calc.tests.TestCalculator) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.007s

OK
Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
  • 溫馨提示: parseString 通過測試,先 git commit,再繼續修改 evalString

提交程式碼

$ git add .
$ git commit -m "parseString of '3+2': ok"

修改 calc/tests.py,增加 evalString 測試項目

    def test_num_op_num(self):
        evalString = self.calc.evalString
        self.assertEqual(evalString('3+2'), 5)
        self.assertEqual(evalString('3-2'), 1)
        self.assertEqual(evalString('3*2'), 6)
        self.assertEqual(evalString('3/2'), 1.5)

執行 unittest,測試 evalString 方法

$ python manage.py test
...E.
======================================================================
ERROR: test_num_op_num (calc.tests.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/vagrant/myWorkspace/demo/calc/tests.py", line 32, in test_num_op_num
    self.assertEqual(evalString('3+2'), 5)
  File "/home/vagrant/myWorkspace/demo/calc/calculator.py", line 34, in evalString
    return self.evalStack(self.exprStack)
  File "/home/vagrant/myWorkspace/demo/calc/calculator.py", line 29, in evalStack
    return float(op)
ValueError: could not convert string to float: '+'

----------------------------------------------------------------------
Ran 5 tests in 0.007s

FAILED (errors=1)
Destroying test database for alias 'default'...
  • 溫馨提示: evalStack 看不懂 + 符號

修改 calc/calculator.py,完善 evalStack 方法滿足測試

from pyparsing import nums, Word, StringEnd, ParseException, Literal, ZeroOrMore
from calc.scalc import SimpleCalculator

class Calculator:

    def __init__(self):
        self.exprStack = []
        def pushStack(s, l, t):
            self.exprStack.append(t[0])

        integer = Word(nums).addParseAction(pushStack)
        op = Literal('+') | Literal('-') | Literal('*') | Literal('/')
        expr = integer + ZeroOrMore((op + integer).addParseAction(pushStack))

        self.expr = expr + StringEnd()

        calc = SimpleCalculator()
        self.opfun = {
                '+' : (lambda a, b: calc.add(a,b)),
                '-' : (lambda a, b: calc.sub(a,b)),
                '*' : (lambda a, b: calc.mul(a,b)),
                '/' : (lambda a, b: calc.div(a,b)) }

    def evalStack(self, stack):
        op = stack.pop()
        if op in '+-*/':
            op2 = self.evalStack(stack)
            op1 = self.evalStack(stack)
            return self.opfun[op](op1, op2)
        else:
            return float(op)

    ...()
  • 初始化時產生 SimpleCalculator 實例,用來執行 +, -, *, / 的運算,並產生 opfun dictionary,對應 +, -, *, / 與負責運算的匿名函數
  • evalStack 方法中,如果看到 op 等於 +, -, *, /,再取出兩個運算元,呼叫匿名函數執行計算,然後回傳結果

執行 unittest,測試 evalStack 方法

$ python manage.py test
Creating test database for alias 'default'...
...E.
======================================================================
ERROR: test_num_op_num (calc.tests.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  ...(略)
  File "/home/vagrant/myWorkspace/demo/calc/calculator.py", line 25, in <lambda>
    '+' : (lambda a, b: calc.add(a,b)),
  File "/home/vagrant/myWorkspace/demo/calc/scalc.py", line 6, in add
    raise NotImplementedError
NotImplementedError

----------------------------------------------------------------------
Ran 5 tests in 0.007s

FAILED (errors=1)
Destroying test database for alias 'default'...
  • 溫馨提示: 發生 NotImplementedErrorSimpleCalculator.add 方法還沒完成 :(

是的,請用力想像這個功能很複雜,另一個團隊正在努力開發中

目前 calc/scalc.py 長這樣:

class SimpleCalculator:

    def add(self, a, b):
        raise NotImplementedError

    def sub(self, a, b):
        raise NotImplementedError

    def mul(self, a, b):
        raise NotImplementedError

    def div(self, a, b):
        raise NotImplementedError

修改 Calculator - 依賴注入 (Dependency Injection, DI)

DI 相關說明請參考 INVERSION OF CONTROL, DEPENDENCY INVERSION PRINCIPLE & DEPENDENCY INJECTION

除了乾等另一個團隊完成底層的功能,還有另一條路可走:使用測試替身 (test double),偽造底層的功能

修改 calc/calculator.py,讓 SimpleCalculator 在初始化時傳入,而不是自己產生

class Calculator:

    def __init__(self, calc):
        ...()

修改測試程式 calc/tests.py,偽造 SimpleCalculator 實例在 Calculator 初始化時傳入

...()
from calc.scalc import SimpleCalculator
from unittest.mock import MagicMock

class TestCalculator(TestCase):

    def setUp(self):
        add_dict = {(3,2) : 5}
        sub_dict = {(3,2) : 1}
        mul_dict = {(3,2) : 6}
        div_dict = {(3,2) : 1.5}

        def add(*args):
            return add_dict[args]
        def sub(*args):
            return sub_dict[args]
        def mul(*args):
            return mul_dict[args]
        def div(*args):
            return div_dict[args]

        scalc = SimpleCalculator()
        scalc.add = MagicMock(side_effect = add)
        scalc.sub = MagicMock(side_effect = sub)
        scalc.mul = MagicMock(side_effect = mul)
        scalc.div = MagicMock(side_effect = div)

        self.calc = Calculator(scalc)

    ...()
  • 偽造 SimpleCalculator.add 方法,不是真的要完成所有功能,只要滿足測試中的呼叫即可
  • 以測試案例 self.assertEqual(evalString('3+2'), 5) 為例,只要透過事先準備的資料,當呼叫 SimpleCalculator.add(3,2) 跑去 add_dict 查表得到 (3,2) : 5,回傳 5 當作結果就能在測試中扮演好 SimpleCalculator.add 的角色

執行 unittest,測試 DI 與偽造 SimpleCalculator 效果

$ python manage.py test
Creating test database for alias 'default'...
.....
----------------------------------------------------------------------
Ran 5 tests in 0.012s

OK
Destroying test database for alias 'default'...
  • 溫馨提示: You make it!

趕快 git commit 吧

$ git add .
$ git commit -m "DI and mock of SimpleCalculator"

修改 Calculator - 處理先乘除後加減

修改 calc/tests.py,增加“加減乘除”混合運算測試

    def setUp(self):
        add_dict = {(3,2) : 5, (4,6) : 10}
        sub_dict = {(3,2) : 1, (9,6) : 3}
        mul_dict = {(3,2) : 6}
        div_dict = {(3,2) : 1.5, (2,1) : 2}
        ...()

    def test_order_of_operations(self):
        evalString = self.calc.evalString
        self.assertEqual(evalString('4+3*2'), 10)
        self.assertEqual(evalString('9-3*2+2/1'), 5)

執行 unittest,測試 evalString

$ python manage.py test
...(略)
======================================================================
FAIL: test_order_of_operations (calc.tests.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/vagrant/myWorkspace/demo/calc/tests.py", line 62, in test_order_of_operations
    self.assertEqual(evalString('4+3*2'), 10)
AssertionError: 14.0 != 10
  • 溫馨提示: 現在的 evalString 沒有做到先乘除後加減

修改 calc/calculator.py,讓乘除優先於加減

...()

"""
integer :: '0'...'9'*
addop   :: '+' | '-'
mulop   :: '*' | '/'
atom    :: integer
term    :: atom [mulop atom]*
expr    :: term [addop term]*
"""

class Calculator:

    def __init__(self, calc):
        self.exprStack = []
        def pushStack(s, l, t):
            self.exprStack.append(t[0])

        integer = Word(nums).addParseAction(pushStack)
        addop = Literal('+') | Literal('-')
        mulop = Literal('*') | Literal('/')

        atom = integer
        term = atom + ZeroOrMore((mulop + atom).addParseAction(pushStack))
        expr = term + ZeroOrMore((addop + term).addParseAction(pushStack))
        self.expr = expr + StringEnd()

        self.opfun = {
                '+' : (lambda a, b: calc.add(a,b)),
                '-' : (lambda a, b: calc.sub(a,b)),
                '*' : (lambda a, b: calc.mul(a,b)),
                '/' : (lambda a, b: calc.div(a,b)) }

    ...()

執行 unittest,測試 evalString

$ python manage.py test
Creating test database for alias 'default'...
......
----------------------------------------------------------------------
Ran 6 tests in 0.018s

OK
Destroying test database for alias 'default'...

溫馨提示: 搞定先乘除後加減

趕快 git commit

$ git add .
$ git commit -m "evalString can handle the order of operations"

修改 Calculator - 處理括號運算

修改 calc/tests.py,增加括號運算測試

    def setUp(self):
        add_dict = {(3,2) : 5, (4,6) : 10, (4,3) : 7, (2,2) : 4}
        sub_dict = {(3,2) : 1, (9,6) : 3, (9,3) : 6}
        mul_dict = {(3,2) : 6, (7,2) : 14, (6,4) : 24}
        div_dict = {(3,2) : 1.5, (2,1) : 2, (24,1) : 24}
        ...()

    def test_parentheses(self):
        evalString = self.calc.evalString
        self.assertEqual(evalString('(4+3)*2'), 14)
        self.assertEqual(evalString('(9-3)*(2+2)/1'), 24)

執行 unittest,測試 evalString

$ python manage.py test
...(略)
======================================================================
FAIL: test_parentheses (calc.tests.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/vagrant/myWorkspace/demo/calc/tests.py", line 67, in test_parentheses
    self.assertEqual(evalString('(4+3)*2'), 14)
AssertionError: 'Invalid Input' != 14
  • 溫韾提醒: evalString 看不懂括號

修改 calc/calculator.py,讓程式可以處理括號

from pyparsing import nums, Word, StringEnd, ParseException, Literal, ZeroOrMore, Forward

"""
integer :: '0'...'9'*
addop   :: '+' | '-'
mulop   :: '*' | '/'
atom    :: integer | '(' + expr + ')'
term    :: atom [mulop atom]*
expr    :: term [addop term]*
"""

class Calculator:

    def __init__(self, calc):
        self.exprStack = []
        def pushStack(s, l, t):
            self.exprStack.append(t[0])

        integer = Word(nums).addParseAction(pushStack)
        addop = Literal('+') | Literal('-')
        mulop = Literal('*') | Literal('/')
        lpar = Literal('(')
        rpar = Literal(')')

        expr = Forward()
        atom = integer | lpar + expr + rpar
        term = atom + ZeroOrMore((mulop + atom).addParseAction(pushStack))
        expr << term + ZeroOrMore((addop + term).addParseAction(pushStack))
        self.expr = expr + StringEnd()

        self.opfun = {
                '+' : (lambda a, b: calc.add(a,b)),
                '-' : (lambda a, b: calc.sub(a,b)),
                '*' : (lambda a, b: calc.mul(a,b)),
                '/' : (lambda a, b: calc.div(a,b)) }

    ...()

執行 unittest,測試 evalString

$ python manage.py test
Creating test database for alias 'default'...
.......
----------------------------------------------------------------------
Ran 7 tests in 0.021s

OK
Destroying test database for alias 'default'...
  • 溫韾提醒: 測試通過

趕快 git commit

$ git add .
$ git commit -m "evalString can handle parentheses"

好消息 SimpleCalculator 終於完成

完成的 scalc.py 如下:

class SimpleCalculator:

    def add(self, a, b):
        return a+b

    def sub(self, a, b):
        return a-b

    def mul(self, a, b):
        return a*b

    def div(self, a, b):
        return a/b

放上 git repository

$ git add calc/scalc.py
$ git commit -m "scalc is finished"

修改 calc/calculator.py,預設使用 SimpleCalculator

from calc.scalc import SimpleCalculator
...()

class Calculator:

    def __init__(self, calc = SimpleCalculator()):
    ...()

修改 calc/tests.py,移除測試替身

    def setUp(self):
        self.calc = Calculator()

執行 unittest,看看重構測試有沒有改爛

$ python manage.py  test
Creating test database for alias 'default'...
.......
----------------------------------------------------------------------
Ran 7 tests in 0.015s

OK
Destroying test database for alias 'default'...
  • 溫馨提示: Well done!

趕快 git commit

$ git add .
$ git commit -m "remove test double"

一口氣測試交換律、結合律、分配律

修改 calc/tests.py,增加相關測試

    def test_commutative_property(self):
        evalString = self.calc.evalString
        self.assertEqual(evalString('3+4'), evalString('4+3'))
        self.assertEqual(evalString('2*5'), evalString('5*2'))

    def test_associative_property(self):
        evalString = self.calc.evalString
        self.assertEqual(evalString('(5+2) + 1'), evalString('5 + (2+1)'))
        self.assertEqual(evalString('(5*2) * 3'), evalString('5 * (2*3)'))

    def test_distributive_property(self):
        evalString = self.calc.evalString
        self.assertEqual(evalString('2 * (1+3)'), evalString('(2*1) + (2*3)'))
        self.assertEqual(evalString('(1+3) * 2'), evalString('(1*2) + (3*2)'))

執行 unittest,測試 evalString

$ python manage.py  test
Creating test database for alias 'default'...
..........
----------------------------------------------------------------------
Ran 10 tests in 0.021s

OK
Destroying test database for alias 'default'...
  • 溫馨提示: Good job! 不用修改 Calculator 已經滿足測試

趕快放上 git repository

$ git add .
$ git commit -m "satisfy commutative, associative, distributive properties"

回過頭來驗證是否滿足 features/scenarios

$ python manage.py behave

Creating test database for alias 'default'...
Feature: Web calculator # features/calc.feature:3
  As a student
  In order to finish my homework
  I want to do arithmatical operations
  Scenario Outline: do simple operations -- @1.1   # features/calc.feature:16
    Given I enter 3 + 2                            # features/steps/calc.py:3 0.000s
    When I press "=" button                        # features/steps/calc.py:7 0.001s
    Then I get the answer 5                        # features/steps/calc.py:12 0.000s

  Scenario Outline: do simple operations -- @1.2   # features/calc.feature:17
    Given I enter 3 - 2                            # features/steps/calc.py:3 0.000s
    When I press "=" button                        # features/steps/calc.py:7 0.001s
    Then I get the answer 1                        # features/steps/calc.py:12 0.000s

  Scenario Outline: do simple operations -- @1.3   # features/calc.feature:18
    Given I enter 3 * 2                            # features/steps/calc.py:3 0.000s
    When I press "=" button                        # features/steps/calc.py:7 0.001s
    Then I get the answer 6                        # features/steps/calc.py:12 0.000s

  Scenario Outline: do simple operations -- @1.4   # features/calc.feature:19
    Given I enter 3 / 2                            # features/steps/calc.py:3 0.000s
    When I press "=" button                        # features/steps/calc.py:7 0.001s
    Then I get the answer 1.5                      # features/steps/calc.py:12 0.000s

  Scenario Outline: do simple operations -- @1.5   # features/calc.feature:20
    Given I enter 3 +-*/ 2                         # features/steps/calc.py:3 0.000s
    When I press "=" button                        # features/steps/calc.py:7 0.001s
    Then I get the answer Invalid Input            # features/steps/calc.py:12 0.000s

  Scenario Outline: do simple operations -- @1.6   # features/calc.feature:21
    Given I enter hello world                      # features/steps/calc.py:3 0.000s
    When I press "=" button                        # features/steps/calc.py:7 0.001s
    Then I get the answer Invalid Input            # features/steps/calc.py:12 0.000s

  Scenario Outline: satisfy commutative property -- @1.1   # features/calc.feature:30
    When I enter 3 + 4 first                               # features/steps/calc.py:21 0.001s
    And I enter 4 + 3 again                                # features/steps/calc.py:26 0.001s
    Then I get the same answer                             # features/steps/calc.py:31 0.000s

  Scenario Outline: satisfy commutative property -- @1.2   # features/calc.feature:31
    When I enter 2 * 5 first                               # features/steps/calc.py:21 0.001s
    And I enter 5 * 2 again                                # features/steps/calc.py:26 0.001s
    Then I get the same answer                             # features/steps/calc.py:31 0.000s

  Scenario Outline: satisfy associative property -- @1.1   # features/calc.feature:40
    When I enter (2 + 3) + 4 first                         # features/steps/calc.py:21 0.001s
    And I enter 2 + (3 + 4) again                          # features/steps/calc.py:26 0.001s
    Then I get the same answer                             # features/steps/calc.py:31 0.000s

  Scenario Outline: satisfy associative property -- @1.2   # features/calc.feature:41
    When I enter 2 * (3 * 4) first                         # features/steps/calc.py:21 0.001s
    And I enter (2 * 3) * 4 again                          # features/steps/calc.py:26 0.001s
    Then I get the same answer                             # features/steps/calc.py:31 0.000s

  Scenario Outline: satisfy distributive property -- @1.1   # features/calc.feature:50
    When I enter 2 * (1 + 3) first                          # features/steps/calc.py:21 0.001s
    And I enter (2*1) + (2*3) again                         # features/steps/calc.py:26 0.002s
    Then I get the same answer                              # features/steps/calc.py:31 0.000s

  Scenario Outline: satisfy distributive property -- @1.2   # features/calc.feature:51
    When I enter (1 + 3) * 2 first                          # features/steps/calc.py:21 0.001s
    And I enter (1*2) + (3*2) again                         # features/steps/calc.py:26 0.001s
    Then I get the same answer                              # features/steps/calc.py:31 0.000s

1 feature passed, 0 failed, 0 skipped
12 scenarios passed, 0 failed, 0 skipped
36 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.024s
Destroying test database for alias 'default'...
  • 溫馨提示: 太棒了,開發的功能已經滿足規格書 (Happy Path)

Watch your back!

測試驅動另一個重要貢獻是自動化,在開始 TDD 的時候我設定了 Jenkins,所以之後的測試與程式碼都會因為 git commit 觸發自動化測試,一旦有異常就會透過紅燈或寄信通知開發群組相關人員。

下圖為之前開發歷程的報告,包含 Build History, Code Coverage, Code Analysis, Test Result Trend。

demo reports

  • Code Coverage (代碼覆蓋) - 軟體測試中的一種度量,描述程式中源代碼被測試的比例和程度,所得比例稱為代碼覆蓋率。
  • Code Violations (pylint) - 程式碼靜態檢查結果,顯示程式碼品質符不符合規範。
  • Test Result Trend - 測試成功/失敗趨勢圖 (count 測試案例的數目)。

Django View

底層 Calculator 終於開發完成,可以搭配 View 提供 GUI 來一場 Live demo。

新增 calc/templates/ 目錄,再增加一個網頁範本 calc/templates/calculator.html

<form ation="." method="post">
    {% csrf_token %}
    <input id="expr" type="text" name="expr" value="{{ value }}">
    <input type="submit" value="=">
</form>

修改 calc/views.py,新增 calculator view

from django.shortcuts import render
from django.http import HttpResponse
from .calculator import Calculator

# Create your views here.
def calc(request):
    value = ''
    if request.method == 'POST':
        calc = Calculator()
        expr = request.POST['expr']
        value = calc.evalString(expr)
    return render(request, 'calculator.html', {'value': value})

修改 demo/urls.py,告訴 Django 如何處理 url

...()
import calc.views as calc_views

urlpatterns = [
    ...()
    url(r'^$', calc_views.calc),
]

啟動網頁伺服器

$ python manage.py runserver 0.0.0.0:8000
Performing system checks...

System check identified no issues (0 silenced).

You have unapplied migrations; your app may not work properly until they are applied.
Run 'python manage.py migrate' to apply them.

June 27, 2016 - 01:58:25
Django version 1.9.7, using settings 'demo.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.

開啟瀏覽器,連接 http://192.168.33.10:8000/ (虛擬機),輸入運算式按下 = 得到答案。範例如下:

demo calc1 demo calc2

趕快把 template/view/URL 相關程式碼放上 git repository

$ git add .
$ git commit -m "add template/view/URL of web calculator"

關於 BDD 的一點小感想:使用 Gherkin 描述產品規格,產生測試步驟 (steps) 的測試框架 (如 Cucumber, Behave),與錄製網頁、App點擊行為實現自動化測試的工具 (如 Selenium),在本質上有很大的差異。前者用來溝通、討論、定義,然後把規格變成自動化測試,後者只能等程式開發完成,被動的依照規格書、驗收條件逐一核對。如果蓋大樓一開始設計圖就畫錯了,事後再好的品管只能確保用對的方式蓋歪掉的樓,相信很多開發人員一定發現了這荒謬的現象,但礙於「按圖施工、保證成功」的開發流程或是「不要問問題、趕快做事情」的企業文化,很多人都默默承受了。

補充資料

環境設定

開發環境相關設定,請參考 Django 設定環境

Jenkins 設定

Jenkins 伺服器建置說明,請參考 Jenkinsdjango-jenkins

開啟瀏覽器,連接 http://192.168.33.10:8080/ (虛擬機)

  • Jenkins 管理首頁
    • New Item
      • Item name: demo
        • Freestyle project
    • Source Code Management
      • Git
        • Repository URL: file:///home/vagrant/myWorkspace/demo
    • Build Triggers
      • Poll SCM
        • Schedule: * * * * *
    • Build Environment
      • pyenv build wrapper
        • The Python version: 3.5.1
    • Build
    • Post-build Actions
      • Publish Cobertura Coverage Report
        • Cobertura xml report pattern: reports/coverage.xml
      • Publish JUnit test result report
        • Test report XMLs: reports/junit.xml
      • Report Violations
        • pylint: reports/pylint.report
    • Save

shell command

PATH=$WORKSPACE/venv/bin:/usr/local/bin:$PATH

if [ ! -d "venv" ]; then
        pyvenv venv
fi
. venv/bin/activate
pip install -r requirements.txt
python manage.py jenkins --enable-coverage