Skip to content

Commit b34b69a

Browse files
committed
[YAML] Basic integration testing framework.
The tests themselves are defined in yaml as a series of pipelines, possibly with some setup code. One of the key features of this framework is that if multiple providers vend the same transform each will be tested to ensure they have consistent behavior.
1 parent f2df753 commit b34b69a

5 files changed

Lines changed: 253 additions & 2 deletions

File tree

sdks/python/apache_beam/testing/util.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,12 +301,12 @@ def expand(self, pcoll):
301301
if not use_global_window:
302302
plain_actual = plain_actual | 'AddWindow' >> ParDo(AddWindow())
303303

304-
plain_actual = plain_actual | 'Match' >> Map(matcher)
304+
return plain_actual | 'Match' >> Map(matcher)
305305

306306
def default_label(self):
307307
return label
308308

309-
actual | AssertThat() # pylint: disable=expression-not-assigned
309+
return actual | AssertThat()
310310

311311

312312
@ptransform_fn
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
"""Runs integration tests in the tests directory."""
19+
20+
import argparse
21+
import contextlib
22+
import copy
23+
import glob
24+
import itertools
25+
import logging
26+
import mock
27+
import os
28+
import random
29+
import re
30+
import sys
31+
import tempfile
32+
import uuid
33+
import unittest
34+
35+
import yaml
36+
from yaml.loader import SafeLoader
37+
38+
import apache_beam as beam
39+
from apache_beam.io import filesystems
40+
from apache_beam.options.pipeline_options import PipelineOptions
41+
from apache_beam.typehints import trivial_inference
42+
from apache_beam.yaml import yaml_mapping
43+
from apache_beam.yaml import yaml_provider
44+
from apache_beam.yaml import yaml_transform
45+
46+
from apache_beam.utils import python_callable
47+
48+
49+
@contextlib.contextmanager
50+
def gcs_temp_dir(bucket):
51+
gcs_tempdir = bucket + '/yaml-' + str(uuid.uuid4())
52+
yield gcs_tempdir
53+
filesystems.FileSystems.delete([gcs_tempdir])
54+
55+
56+
def replace_recursive(spec, vars):
57+
if isinstance(spec, dict):
58+
return {
59+
key: replace_recursive(value, vars)
60+
for (key, value) in spec.items()
61+
}
62+
elif isinstance(spec, list):
63+
return [replace_recursive(value, vars) for value in spec]
64+
elif isinstance(spec, str) and '{' in spec:
65+
try:
66+
return eval('f' + repr(spec), vars)
67+
except Exception as exn:
68+
raise ValueError(f"Error evaluating {spec}: {exn}") from exn
69+
else:
70+
return spec
71+
72+
73+
def transform_types(spec):
74+
if spec.get('type', None) in (None, 'composite', 'chain'):
75+
if 'source' in spec:
76+
yield from transform_types(spec['source'])
77+
for t in spec.get('transforms', []):
78+
yield from transform_types(t)
79+
if 'sink' in spec:
80+
yield from transform_types(spec['sink'])
81+
else:
82+
yield spec['type']
83+
84+
85+
def provider_sets(spec, require_available=False):
86+
"""For transforms that are vended by multiple providers, yields all possible
87+
combinations of providers to use.
88+
"""
89+
all_transform_types = set.union(
90+
*(
91+
set(
92+
transform_types(
93+
yaml_transform.preprocess(copy.deepcopy(p['pipeline']))))
94+
for p in spec['pipelines']))
95+
96+
def filter_to_available(t, providers):
97+
if require_available:
98+
for p in providers:
99+
if not p.available():
100+
raise ValueError("Provider {p} required for {t} is not available.")
101+
return providers
102+
else:
103+
return [p for p in providers if p.available()]
104+
105+
standard_providers = yaml_provider.standard_providers()
106+
multiple_providers = {
107+
t: filter_to_available(t, standard_providers[t])
108+
for t in all_transform_types
109+
if len(filter_to_available(t, standard_providers[t])) > 1
110+
}
111+
if not multiple_providers:
112+
return None, standard_providers
113+
else:
114+
names, provider_lists = zip(*sorted(multiple_providers.items()))
115+
for ix, c in enumerate(itertools.product(*provider_lists)):
116+
yield (
117+
'_'.join(
118+
n + '_' + type(p.underlying_provider()).__name__
119+
for (n, p) in zip(names, c)) + f'_{ix}',
120+
dict(standard_providers, **{n: [p]
121+
for (n, p) in zip(names, c)}))
122+
123+
124+
def create_test_methods(spec):
125+
for suffix, providers in provider_sets(spec):
126+
127+
def test(self, providers=providers): # default arg to capture loop value
128+
vars = {}
129+
with contextlib.ExitStack() as stack:
130+
stack.enter_context(
131+
mock.patch(
132+
'apache_beam.yaml.yaml_provider.standard_providers',
133+
lambda: providers))
134+
for fixture in spec.get('fixtures', []):
135+
vars[fixture['name']] = stack.enter_context(
136+
python_callable.PythonCallableWithSource.
137+
load_from_fully_qualified_name(fixture['type'])(
138+
**yaml_transform.SafeLineLoader.strip_metadata(
139+
fixture.get('config', {}))))
140+
for pipeline_spec in spec['pipelines']:
141+
with beam.Pipeline(options=PipelineOptions(
142+
pickle_library='cloudpickle',
143+
**yaml_transform.SafeLineLoader.strip_metadata(pipeline_spec.get(
144+
'options', {})))) as p:
145+
yaml_transform.expand_pipeline(
146+
p, replace_recursive(pipeline_spec, vars))
147+
148+
yield suffix, test
149+
150+
151+
def parse_test_files(filepattern):
152+
for path in glob.glob(filepattern):
153+
with open(path) as fin:
154+
base_name = f'test_{os.path.basename(path)}'.replace('.', '_')
155+
for suffix, func in create_test_methods(
156+
yaml.load(fin, Loader=yaml_transform.SafeLineLoader)):
157+
if suffix:
158+
yield f'{base_name}_{suffix}', func
159+
else:
160+
yield base_name, func
161+
162+
163+
def createTestSuite(name, filepattern):
164+
return type(name, (unittest.TestCase, ), dict(parse_test_files(filepattern)))
165+
166+
167+
IntegrationTests = createTestSuite(
168+
'IntegrationTests',
169+
os.path.join(os.path.dirname(__file__), 'tests', '*.yaml'))
170+
171+
if __name__ == '__main__':
172+
logging.getLogger().setLevel(logging.INFO)
173+
unittest.main(argv=unknown_args)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
fixtures:
2+
- name: TEMP_DIR
3+
# Need distributed filesystem to be able to read and write from a container.
4+
type: "apache_beam.yaml.integration_tests.gcs_temp_dir"
5+
config:
6+
bucket: "gs://temp-storage-for-end-to-end-tests/temp-it"
7+
8+
pipelines:
9+
- pipeline:
10+
type: chain
11+
transforms:
12+
- type: Create
13+
config:
14+
elements:
15+
- {label: "11a", rank: 0}
16+
- {label: "37a", rank: 1}
17+
- {label: "389a", rank: 2}
18+
- type: WriteToCsv
19+
config:
20+
path: "{TEMP_DIR}/out.csv"
21+
22+
- pipeline:
23+
type: chain
24+
transforms:
25+
- type: ReadFromCsv
26+
config:
27+
path: "{TEMP_DIR}/out.csv*"
28+
- type: AssertEqual
29+
config:
30+
elements:
31+
- {label: "11a", rank: 0}
32+
- {label: "37a", rank: 1}
33+
- {label: "389a", rank: 2}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
fixtures:
2+
- name: TEMP_DIR
3+
# Need distributed filesystem to be able to read and write from a container.
4+
type: "apache_beam.yaml.integration_tests.gcs_temp_dir"
5+
config:
6+
bucket: "gs://temp-storage-for-end-to-end-tests/temp-it"
7+
8+
pipelines:
9+
- pipeline:
10+
type: chain
11+
transforms:
12+
- type: Create
13+
config:
14+
elements:
15+
- {label: "11a", rank: 0}
16+
- {label: "37a", rank: 1}
17+
- {label: "389a", rank: 2}
18+
- type: WriteToJson
19+
config:
20+
path: "{TEMP_DIR}/out.json"
21+
22+
- pipeline:
23+
type: chain
24+
transforms:
25+
- type: ReadFromJson
26+
config:
27+
path: "{TEMP_DIR}/out.json*"
28+
- type: AssertEqual
29+
config:
30+
elements:
31+
- {label: "11a", rank: 0}
32+
- {label: "37a", rank: 1}
33+
- {label: "389a", rank: 2}

sdks/python/apache_beam/yaml/yaml_provider.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
import apache_beam.io
4545
import apache_beam.transforms.util
4646
from apache_beam.portability.api import schema_pb2
47+
from apache_beam.testing.util import assert_that
48+
from apache_beam.testing.util import equal_to
4749
from apache_beam.transforms import external
4850
from apache_beam.transforms import window
4951
from apache_beam.transforms.fully_qualified_named_transform import FullyQualifiedNamedTransform
@@ -585,13 +587,23 @@ def log_and_return(x):
585587
logging.info(x)
586588
return x
587589

590+
class AssertEqual(beam.PTransform):
591+
def __init__(self, elements):
592+
self._elements = elements
593+
594+
def expand(self, pcoll):
595+
return assert_that(
596+
pcoll | beam.Map(lambda row: beam.Row(**row._asdict())),
597+
equal_to(dicts_to_rows(self._elements)))
598+
588599
return InlineProvider({
589600
'Create': create,
590601
'LogForTesting': lambda: beam.Map(log_and_return),
591602
'PyTransform': fully_qualified_named_transform,
592603
'WithSchemaExperimental': with_schema,
593604
'Flatten': Flatten,
594605
'WindowInto': WindowInto,
606+
'AssertEqual': AssertEqual,
595607
},
596608
no_input_transforms=('Create', ))
597609

0 commit comments

Comments
 (0)