support gherkin scenario outline and examples

read scenario outline related tokens in gherkin lexer

* e8b71e6 test lexing of scenario outline with examples
* 13f73f2 update gherkin parser to parse scenario outline with examples
* cfad4fe test scenario outline persing
Этот коммит содержится в:
gedi 2015-06-11 10:48:26 +03:00
родитель 1cc5fde508
коммит 4afb53d310
6 изменённых файлов: 247 добавлений и 51 удалений

Просмотреть файл

@ -87,11 +87,21 @@ func (t Tags) Has(tag Tag) bool {
} }
// Scenario describes the scenario details // Scenario describes the scenario details
//
// if Examples table is not nil, then it
// means that this is an outline scenario
// with a table of examples to be run for
// each and every row
//
// Scenario may have tags which later may
// be used to filter out or run specific
// initialization tasks
type Scenario struct { type Scenario struct {
Title string Title string
Steps []*Step Steps []*Step
Tags Tags Tags Tags
Comment string Examples *Table
Comment string
} }
// Background steps are run before every scenario // Background steps are run before every scenario
@ -243,34 +253,56 @@ func (p *parser) parseFeature() (ft *Feature, err error) {
} }
// there may be tags before scenario // there may be tags before scenario
sc := &Scenario{} var tags Tags
sc.Tags = append(sc.Tags, ft.Tags...) tags = append(tags, ft.Tags...)
if tok.Type == TAGS { if tok.Type == TAGS {
for _, t := range p.parseTags() { for _, t := range p.parseTags() {
if !sc.Tags.Has(t) { if !tags.Has(t) {
sc.Tags = append(sc.Tags, t) tags = append(tags, t)
} }
} }
tok = p.peek() tok = p.peek()
} }
// there must be a scenario otherwise // there must be a scenario or scenario outline otherwise
if tok.Type != SCENARIO { if !tok.OfType(SCENARIO, SCENARIO_OUTLINE) {
return ft, p.err("expected a scenario, but got '"+tok.Type.String()+"' instead", tok.Line) return ft, p.err("expected a scenario or scenario outline, but got '"+tok.Type.String()+"' instead", tok.Line)
} }
sc.Title = tok.Value scenario, err := p.parseScenario()
sc.Comment = tok.Comment if err != nil {
p.next() // jump to scenario steps
if sc.Steps, err = p.parseSteps(); err != nil {
return ft, err return ft, err
} }
ft.Scenarios = append(ft.Scenarios, sc)
scenario.Tags = tags
ft.Scenarios = append(ft.Scenarios, scenario)
} }
return ft, nil return ft, nil
} }
func (p *parser) parseScenario() (s *Scenario, err error) {
tok := p.next()
s = &Scenario{Title: tok.Value, Comment: tok.Comment}
if s.Steps, err = p.parseSteps(); err != nil {
return s, err
}
if examples := p.peek(); examples.Type == EXAMPLES {
p.next() // jump over the peeked token
peek := p.peek()
if peek.Type != TABLE_ROW {
return s, p.err(strings.Join([]string{
"expected a table row,",
"but got '" + peek.Type.String() + "' instead, for scenario outline examples",
}, " "), examples.Line)
}
if s.Examples, err = p.parseTable(); err != nil {
return s, err
}
}
return s, nil
}
func (p *parser) parseSteps() (steps []*Step, err error) { func (p *parser) parseSteps() (steps []*Step, err error) {
for tok := p.peek(); tok.OfType(allSteps...); tok = p.peek() { for tok := p.peek(); tok.OfType(allSteps...); tok = p.peek() {
step := &Step{Text: tok.Value, Comment: tok.Comment} step := &Step{Text: tok.Value, Comment: tok.Comment}
@ -294,11 +326,11 @@ func (p *parser) parseSteps() (steps []*Step, err error) {
tok = p.peek() tok = p.peek()
switch tok.Type { switch tok.Type {
case PYSTRING: case PYSTRING:
if err := p.parsePystring(step); err != nil { if step.PyString, err = p.parsePystring(); err != nil {
return steps, err return steps, err
} }
case TABLE_ROW: case TABLE_ROW:
if err := p.parseTable(step); err != nil { if step.Table, err = p.parseTable(); err != nil {
return steps, err return steps, err
} }
default: default:
@ -312,7 +344,7 @@ func (p *parser) parseSteps() (steps []*Step, err error) {
return steps, nil return steps, nil
} }
func (p *parser) parsePystring(s *Step) error { func (p *parser) parsePystring() (*PyString, error) {
var tok *Token var tok *Token
started := p.next() // skip the start of pystring started := p.next() // skip the start of pystring
var lines []string var lines []string
@ -320,27 +352,28 @@ func (p *parser) parsePystring(s *Step) error {
lines = append(lines, tok.Text) lines = append(lines, tok.Text)
} }
if tok.Type == EOF { if tok.Type == EOF {
return fmt.Errorf("pystring which was opened on %s:%d was not closed", p.path, started.Line) return nil, fmt.Errorf("pystring which was opened on %s:%d was not closed", p.path, started.Line)
} }
s.PyString = &PyString{Body: strings.Join(lines, "\n")} return &PyString{
return nil Body: strings.Join(lines, "\n"),
}, nil
} }
func (p *parser) parseTable(s *Step) error { func (p *parser) parseTable() (*Table, error) {
s.Table = &Table{} tbl := &Table{}
for row := p.peek(); row.Type == TABLE_ROW; row = p.peek() { for row := p.peek(); row.Type == TABLE_ROW; row = p.peek() {
var cols []string var cols []string
for _, r := range strings.Split(strings.Trim(row.Value, "|"), "|") { for _, r := range strings.Split(strings.Trim(row.Value, "|"), "|") {
cols = append(cols, strings.TrimFunc(r, unicode.IsSpace)) cols = append(cols, strings.TrimFunc(r, unicode.IsSpace))
} }
// ensure the same colum number for each row // ensure the same colum number for each row
if len(s.Table.rows) > 0 && len(s.Table.rows[0]) != len(cols) { if len(tbl.rows) > 0 && len(tbl.rows[0]) != len(cols) {
return p.err("table row has not the same number of columns compared to previous row", row.Line) return tbl, p.err("table row has not the same number of columns compared to previous row", row.Line)
} }
s.Table.rows = append(s.Table.rows, cols) tbl.rows = append(tbl.rows, cols)
p.next() // jump over the peeked token p.next() // jump over the peeked token
} }
return nil return tbl, nil
} }
func (p *parser) parseTags() (tags Tags) { func (p *parser) parseTags() (tags Tags) {

Просмотреть файл

@ -9,14 +9,16 @@ import (
) )
var matchers = map[string]*regexp.Regexp{ var matchers = map[string]*regexp.Regexp{
"feature": regexp.MustCompile("^(\\s*)Feature:\\s*([^#]*)(#.*)?"), "feature": regexp.MustCompile("^(\\s*)Feature:\\s*([^#]*)(#.*)?"),
"scenario": regexp.MustCompile("^(\\s*)Scenario:\\s*([^#]*)(#.*)?"), "scenario": regexp.MustCompile("^(\\s*)Scenario:\\s*([^#]*)(#.*)?"),
"background": regexp.MustCompile("^(\\s*)Background:(\\s*#.*)?"), "scenario_outline": regexp.MustCompile("^(\\s*)Scenario Outline:\\s*([^#]*)(#.*)?"),
"step": regexp.MustCompile("^(\\s*)(Given|When|Then|And|But)\\s+([^#]*)(#.*)?"), "examples": regexp.MustCompile("^(\\s*)Examples:(\\s*#.*)?"),
"comment": regexp.MustCompile("^(\\s*)#(.+)"), "background": regexp.MustCompile("^(\\s*)Background:(\\s*#.*)?"),
"pystring": regexp.MustCompile("^(\\s*)\\\"\\\"\\\""), "step": regexp.MustCompile("^(\\s*)(Given|When|Then|And|But)\\s+([^#]*)(#.*)?"),
"tags": regexp.MustCompile("^(\\s*)@([^#]*)(#.*)?"), "comment": regexp.MustCompile("^(\\s*)#(.+)"),
"table_row": regexp.MustCompile("^(\\s*)\\|([^#]*)(#.*)?"), "pystring": regexp.MustCompile("^(\\s*)\\\"\\\"\\\""),
"tags": regexp.MustCompile("^(\\s*)@([^#]*)(#.*)?"),
"table_row": regexp.MustCompile("^(\\s*)\\|([^#]*)(#.*)?"),
} }
type lexer struct { type lexer struct {
@ -145,6 +147,27 @@ func (l *lexer) read() *Token {
Comment: strings.Trim(m[3], " #"), Comment: strings.Trim(m[3], " #"),
} }
} }
// scenario outline
if m := matchers["scenario_outline"].FindStringSubmatch(line); len(m) > 0 {
return &Token{
Type: SCENARIO_OUTLINE,
Indent: len(m[1]),
Line: l.lines - 1,
Value: strings.TrimSpace(m[2]),
Text: line,
Comment: strings.Trim(m[3], " #"),
}
}
// examples
if m := matchers["examples"].FindStringSubmatch(line); len(m) > 0 {
return &Token{
Type: EXAMPLES,
Indent: len(m[1]),
Line: l.lines - 1,
Text: line,
Comment: strings.Trim(m[2], " #"),
}
}
// text // text
text := strings.TrimLeftFunc(line, unicode.IsSpace) text := strings.TrimLeftFunc(line, unicode.IsSpace)
return &Token{ return &Token{

Просмотреть файл

@ -5,7 +5,7 @@ import (
"testing" "testing"
) )
var lexerSamples = map[string]string{ var testLexerSamples = map[string]string{
"feature": `Feature: gherkin lexer "feature": `Feature: gherkin lexer
in order to run features in order to run features
as gherkin lexer as gherkin lexer
@ -27,10 +27,22 @@ var lexerSamples = map[string]string{
| name | lastname | num | | name | lastname | num |
| Jack | Sparrow | 4 | | Jack | Sparrow | 4 |
| John | Doe | 79 |`, | John | Doe | 79 |`,
"scenario_outline_with_examples": `Scenario Outline: ls supports kinds of options
Given I am in a directory "test"
And I have a file named "foo"
And I have a file named "bar"
When I run "ls" with options "<options>"
Then I should see "<result>"
Examples:
| options | result |
| -t | bar foo |
| -tr | foo bar |`,
} }
func Test_feature_read(t *testing.T) { func Test_feature_read(t *testing.T) {
l := newLexer(strings.NewReader(lexerSamples["feature"])) l := newLexer(strings.NewReader(testLexerSamples["feature"]))
tok := l.read() tok := l.read()
if tok.Type != FEATURE { if tok.Type != FEATURE {
t.Fatalf("Expected a 'feature' type, but got: '%s'", tok.Type) t.Fatalf("Expected a 'feature' type, but got: '%s'", tok.Type)
@ -99,16 +111,16 @@ func Test_feature_read(t *testing.T) {
func Test_minimal_feature(t *testing.T) { func Test_minimal_feature(t *testing.T) {
file := strings.Join([]string{ file := strings.Join([]string{
lexerSamples["feature"] + "\n", testLexerSamples["feature"] + "\n",
indent(2, lexerSamples["background"]), indent(2, testLexerSamples["background"]),
indent(4, lexerSamples["step_given"]) + "\n", indent(4, testLexerSamples["step_given"]) + "\n",
indent(2, lexerSamples["comment"]), indent(2, testLexerSamples["comment"]),
indent(2, lexerSamples["scenario"]), indent(2, testLexerSamples["scenario"]),
indent(4, lexerSamples["step_given"]), indent(4, testLexerSamples["step_given"]),
indent(4, lexerSamples["step_when"]), indent(4, testLexerSamples["step_when"]),
indent(4, lexerSamples["step_then"]), indent(4, testLexerSamples["step_then"]),
}, "\n") }, "\n")
l := newLexer(strings.NewReader(file)) l := newLexer(strings.NewReader(file))
@ -142,9 +154,9 @@ func Test_minimal_feature(t *testing.T) {
func Test_table_row_reading(t *testing.T) { func Test_table_row_reading(t *testing.T) {
file := strings.Join([]string{ file := strings.Join([]string{
indent(2, lexerSamples["background"]), indent(2, testLexerSamples["background"]),
indent(4, lexerSamples["step_given_table"]), indent(4, testLexerSamples["step_given_table"]),
indent(4, lexerSamples["step_given"]), indent(4, testLexerSamples["step_given"]),
}, "\n") }, "\n")
l := newLexer(strings.NewReader(file)) l := newLexer(strings.NewReader(file))
@ -179,3 +191,31 @@ func Test_table_row_reading(t *testing.T) {
t.Fatalf("table row value '%s' was not expected", values[2]) t.Fatalf("table row value '%s' was not expected", values[2])
} }
} }
func Test_lexing_of_scenario_outline(t *testing.T) {
l := newLexer(strings.NewReader(testLexerSamples["scenario_outline_with_examples"]))
var tokens []TokenType
for tok := l.read(); tok.Type != EOF; tok = l.read() {
tokens = append(tokens, tok.Type)
}
expected := []TokenType{
SCENARIO_OUTLINE,
GIVEN,
AND,
AND,
WHEN,
THEN,
NEW_LINE,
EXAMPLES,
TABLE_ROW,
TABLE_ROW,
TABLE_ROW,
}
for i := 0; i < len(expected); i++ {
if expected[i] != tokens[i] {
t.Fatalf("expected token '%s' at position: %d, is not the same as actual token: '%s'", expected[i], i, tokens[i])
}
}
}

Просмотреть файл

@ -35,7 +35,9 @@ func Test_parse_feature_file(t *testing.T) {
testStepSamples["step_group_another"] + "\n", testStepSamples["step_group_another"] + "\n",
// scenario - no steps yet // scenario - no steps yet
indent(2, "@todo"), // cust - tag is repeated indent(2, "@todo"), // cust - tag is repeated
indent(2, "Scenario: user is able to reset his password"), indent(2, "Scenario: user is able to reset his password") + "\n",
// scenario outline
testLexerSamples["scenario_outline_with_examples"],
}, "\n") }, "\n")
p := &parser{ p := &parser{
@ -79,9 +81,22 @@ func Test_parse_feature_file(t *testing.T) {
TAGS, TAGS,
SCENARIO, SCENARIO,
NEW_LINE,
SCENARIO_OUTLINE,
GIVEN,
AND,
AND,
WHEN,
THEN,
NEW_LINE,
EXAMPLES,
TABLE_ROW,
TABLE_ROW,
TABLE_ROW,
}, t) }, t)
ft.assertHasNumScenarios(3, t) ft.assertHasNumScenarios(4, t)
ft.Scenarios[0].assertHasNumTags(2, t) ft.Scenarios[0].assertHasNumTags(2, t)
ft.Scenarios[0].assertHasTag("global-one", t) ft.Scenarios[0].assertHasTag("global-one", t)
@ -96,4 +111,6 @@ func Test_parse_feature_file(t *testing.T) {
ft.Scenarios[2].assertHasTag("global-one", t) ft.Scenarios[2].assertHasTag("global-one", t)
ft.Scenarios[2].assertHasTag("cust", t) ft.Scenarios[2].assertHasTag("cust", t)
ft.Scenarios[2].assertHasTag("todo", t) ft.Scenarios[2].assertHasTag("todo", t)
ft.Scenarios[3].assertHasNumTags(2, t)
} }

77
gherkin/scenario_test.go Обычный файл
Просмотреть файл

@ -0,0 +1,77 @@
package gherkin
import (
"strings"
"testing"
)
func (s *Scenario) assertTitle(title string, t *testing.T) {
if s.Title != title {
t.Fatalf("expected scenario title to be '%s', but got '%s'", title, s.Title)
}
}
func (s *Scenario) assertStep(text string, t *testing.T) *Step {
for _, stp := range s.Steps {
if stp.Text == text {
return stp
}
}
t.Fatal("expected scenario '%s' to have step: '%s', but it did not", s.Title, text)
return nil
}
func (s *Scenario) assertExampleRow(t *testing.T, num int, cols ...string) {
if s.Examples == nil {
t.Fatalf("outline scenario '%s' has no examples", s.Title)
}
if len(s.Examples.rows) <= num {
t.Fatalf("outline scenario '%s' table has no row: %d", s.Title, num)
}
if len(s.Examples.rows[num]) != len(cols) {
t.Fatalf("outline scenario '%s' table row length, does not match expected: %d", s.Title, len(cols))
}
for i, col := range s.Examples.rows[num] {
if col != cols[i] {
t.Fatalf("outline scenario '%s' table row %d, column %d - value '%s', does not match expected: %s", s.Title, num, i, col, cols[i])
}
}
}
func Test_parse_scenario_outline(t *testing.T) {
p := &parser{
lx: newLexer(strings.NewReader(testLexerSamples["scenario_outline_with_examples"])),
path: "usual.feature",
ast: newAST(),
}
s, err := p.parseScenario()
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
s.assertTitle("ls supports kinds of options", t)
p.ast.assertMatchesTypes([]TokenType{
SCENARIO_OUTLINE,
GIVEN,
AND,
AND,
WHEN,
THEN,
NEW_LINE,
EXAMPLES,
TABLE_ROW,
TABLE_ROW,
TABLE_ROW,
}, t)
s.assertStep(`I am in a directory "test"`, t)
s.assertStep(`I have a file named "foo"`, t)
s.assertStep(`I have a file named "bar"`, t)
s.assertStep(`I run "ls" with options "<options>"`, t)
s.assertStep(`I should see "<result>"`, t)
s.assertExampleRow(t, 0, "options", "result")
s.assertExampleRow(t, 1, "-t", "bar foo")
s.assertExampleRow(t, 2, "-tr", "foo bar")
}

Просмотреть файл

@ -20,6 +20,8 @@ const (
FEATURE FEATURE
BACKGROUND BACKGROUND
SCENARIO SCENARIO
SCENARIO_OUTLINE
EXAMPLES
steps steps
GIVEN GIVEN
@ -51,6 +53,10 @@ func (t TokenType) String() string {
return "background" return "background"
case SCENARIO: case SCENARIO:
return "scenario" return "scenario"
case SCENARIO_OUTLINE:
return "scenario outline"
case EXAMPLES:
return "examples"
case GIVEN: case GIVEN:
return "given step" return "given step"
case WHEN: case WHEN: