├── example ├── src │ ├── stub │ │ └── .keepdir │ ├── test.proto │ └── servicer.py ├── README.md └── test_example.py ├── pytest_grpc ├── __init__.py └── plugin.py ├── .gitignore ├── .bumpversion.cfg ├── Makefile ├── LICENSE ├── setup.py └── README.md /example/src/stub/.keepdir: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest_grpc/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.8.0' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox 2 | build 3 | dist 4 | *.egg-info/ 5 | example/src/stub/test_pb2.py 6 | example/src/stub/test_pb2_grpc.py 7 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | Compile with: 2 | 3 | ```bash 4 | pip install grpcio grpcio-tools 5 | python -m grpc_tools.protoc -Isrc --python_out=src/stub --grpc_python_out=src/stub src/test.proto 6 | PYTHONPATH=src py.test 7 | # or 8 | PYTHONPATH=src py.test --grpc-fake-server 9 | ``` 10 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.8.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:pytest_grpc/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: clean 3 | clean: 4 | -rm -rf build dist *.egg-info htmlcov .eggs 5 | 6 | minor: 7 | bumpversion minor 8 | 9 | major: 10 | bumpversion major 11 | 12 | patch: 13 | bumpversion patch 14 | 15 | publish: 16 | pip3 install wheel twine 17 | python3 setup.py sdist bdist_wheel 18 | twine upload dist/* 19 | 20 | upload: clean publish 21 | -------------------------------------------------------------------------------- /example/src/test.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package test.v1; 4 | 5 | 6 | service EchoService { 7 | rpc handler(EchoRequest) returns (EchoResponse) { 8 | } 9 | 10 | rpc error_handler(EchoRequest) returns (EchoResponse) { 11 | } 12 | 13 | rpc blocking(Empty) returns (stream EchoResponse) { 14 | } 15 | 16 | rpc unblock(Empty) returns (Empty) { 17 | } 18 | } 19 | 20 | 21 | message EchoRequest { 22 | string name = 1; 23 | } 24 | 25 | message EchoResponse { 26 | string name = 1; 27 | } 28 | 29 | message Empty { 30 | } 31 | -------------------------------------------------------------------------------- /example/src/servicer.py: -------------------------------------------------------------------------------- 1 | from stub.test_pb2 import EchoRequest, EchoResponse, Empty 2 | from stub.test_pb2_grpc import EchoServiceServicer 3 | import threading 4 | 5 | 6 | class Servicer(EchoServiceServicer): 7 | def __init__(self): 8 | self.barrier = threading.Barrier(2) 9 | 10 | def handler(self, request: EchoRequest, context) -> EchoResponse: 11 | return EchoResponse(name=f'test-{request.name}') 12 | 13 | def error_handler(self, request: EchoRequest, context) -> EchoResponse: 14 | raise RuntimeError('Some error') 15 | 16 | def blocking(self, request_stream, context): 17 | for i in range(2): 18 | yield EchoResponse(name=str(i)) 19 | self.barrier.wait() 20 | 21 | def unblock(self, _, context): 22 | self.barrier.wait() 23 | return Empty() 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Denis Kataev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | install_requires = ["pytest>=3.6.0"] 4 | 5 | with open("README.md", "r") as fh: 6 | long_description = fh.read() 7 | 8 | setup( 9 | name="pytest-grpc", 10 | license="MIT", 11 | version='0.8.0', 12 | author="Denis Kataev", 13 | author_email="denis.a.kataev@gmail.com", 14 | url="https://github.com/kataev/pytest-grpc", 15 | platforms=["linux", "osx", "win32"], 16 | packages=find_packages(), 17 | entry_points={ 18 | "pytest11": ["pytest_grpc = pytest_grpc.plugin"] 19 | }, 20 | description='pytest plugin for grpc', 21 | license_files=("LICENSE",), 22 | long_description=long_description, 23 | long_description_content_type="text/markdown", 24 | install_requires=install_requires, 25 | classifiers=[ 26 | "Framework :: Pytest", 27 | "Intended Audience :: Developers", 28 | "License :: OSI Approved :: MIT License", 29 | "Operating System :: POSIX", 30 | "Operating System :: Microsoft :: Windows", 31 | "Operating System :: MacOS :: MacOS X", 32 | "Topic :: Software Development :: Testing", 33 | "Topic :: Software Development :: Quality Assurance", 34 | "Topic :: Utilities", 35 | "Programming Language :: Python", 36 | "Programming Language :: Python :: 3.6", 37 | "Programming Language :: Python :: 3.7", 38 | ], 39 | ) 40 | -------------------------------------------------------------------------------- /example/test_example.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import threading 3 | 4 | from stub.test_pb2 import EchoRequest, Empty 5 | 6 | 7 | @pytest.fixture(scope='module') 8 | def grpc_add_to_server(): 9 | from stub.test_pb2_grpc import add_EchoServiceServicer_to_server 10 | 11 | return add_EchoServiceServicer_to_server 12 | 13 | 14 | @pytest.fixture(scope='module') 15 | def grpc_servicer(): 16 | from servicer import Servicer 17 | 18 | return Servicer() 19 | 20 | 21 | @pytest.fixture(scope='module') 22 | def grpc_stub(grpc_channel): 23 | from stub.test_pb2_grpc import EchoServiceStub 24 | 25 | return EchoServiceStub(grpc_channel) 26 | 27 | 28 | def test_some(grpc_stub): 29 | request = EchoRequest() 30 | response = grpc_stub.handler(request) 31 | 32 | assert response.name == f'test-{request.name}' 33 | 34 | def test_example(grpc_stub): 35 | request = EchoRequest() 36 | response = grpc_stub.error_handler(request) 37 | 38 | assert response.name == f'test-{request.name}' 39 | 40 | 41 | grpc_max_workers = 2 42 | 43 | 44 | def test_blocking(grpc_stub): 45 | stream = grpc_stub.blocking(Empty()) 46 | # after this call the servicer blocks its thread 47 | def call_unblock(): 48 | # with grpc_max_workers = 1 this call could not be executed 49 | grpc_stub.unblock(Empty()) 50 | grpc_stub.unblock(Empty()) 51 | t = threading.Thread(target=call_unblock) 52 | t.start() 53 | for resp in stream: 54 | pass 55 | t.join() 56 | -------------------------------------------------------------------------------- /pytest_grpc/plugin.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from concurrent import futures 3 | 4 | import grpc 5 | import pytest 6 | from grpc._cython.cygrpc import CompositeChannelCredentials, _Metadatum 7 | 8 | 9 | class FakeServer(object): 10 | def __init__(self, pool): 11 | self.handlers = {} 12 | self.pool = pool 13 | 14 | def add_generic_rpc_handlers(self, generic_rpc_handlers): 15 | from grpc._server import _validate_generic_rpc_handlers 16 | _validate_generic_rpc_handlers(generic_rpc_handlers) 17 | 18 | self.handlers.update(generic_rpc_handlers[0]._method_handlers) 19 | 20 | def start(self): 21 | pass 22 | 23 | def stop(self, grace=None): 24 | pass 25 | 26 | def add_secure_port(self, target, server_credentials): 27 | pass 28 | 29 | def add_insecure_port(self, target): 30 | pass 31 | 32 | 33 | class FakeRpcError(RuntimeError, grpc.RpcError): 34 | def __init__(self, code, details): 35 | self._code = code 36 | self._details = details 37 | 38 | def code(self): 39 | return self._code 40 | 41 | def details(self): 42 | return self._details 43 | 44 | 45 | class FakeContext(object): 46 | def __init__(self): 47 | self._invocation_metadata = [] 48 | 49 | def abort(self, code, details): 50 | raise FakeRpcError(code, details) 51 | 52 | def invocation_metadata(self): 53 | return self._invocation_metadata 54 | 55 | 56 | class FakeChannel: 57 | def __init__(self, fake_server, credentials): 58 | self.server = fake_server 59 | self._credentials = credentials 60 | 61 | def __enter__(self): 62 | return self 63 | 64 | def __exit__(self, exc_type, exc_val, exc_tb): 65 | pass 66 | 67 | def fake_method(self, method_name, uri, *args, **kwargs): 68 | handler = self.server.handlers[uri] 69 | real_method = getattr(handler, method_name) 70 | 71 | def fake_handler(request): 72 | context = FakeContext() 73 | 74 | def metadata_callbak(metadata, error): 75 | context._invocation_metadata.extend((_Metadatum(k, v) for k, v in metadata)) 76 | 77 | if self._credentials and isinstance(self._credentials._credentials, CompositeChannelCredentials): 78 | for call_cred in self._credentials._credentials._call_credentialses: 79 | call_cred._metadata_plugin._metadata_plugin(context, metadata_callbak) 80 | future = self.server.pool.submit(real_method, request, context) 81 | return future.result() 82 | 83 | return fake_handler 84 | 85 | def unary_unary(self, *args, **kwargs): 86 | return self.fake_method('unary_unary', *args, **kwargs) 87 | 88 | def unary_stream(self, *args, **kwargs): 89 | return self.fake_method('unary_stream', *args, **kwargs) 90 | 91 | def stream_unary(self, *args, **kwargs): 92 | return self.fake_method('stream_unary', *args, **kwargs) 93 | 94 | def stream_stream(self, *args, **kwargs): 95 | return self.fake_method('stream_stream', *args, **kwargs) 96 | 97 | 98 | @pytest.fixture(scope='module') 99 | def grpc_addr(): 100 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 101 | sock.bind(('localhost', 0)) 102 | return 'localhost:{}'.format(sock.getsockname()[1]) 103 | 104 | 105 | @pytest.fixture(scope='module') 106 | def grpc_interceptors(): 107 | return 108 | 109 | 110 | @pytest.fixture(scope='module') 111 | def _grpc_server(request, grpc_addr, grpc_interceptors): 112 | max_workers = request.config.getoption('grpc-max-workers') 113 | try: 114 | max_workers = max(request.module.grpc_max_workers, max_workers) 115 | except AttributeError: 116 | pass 117 | pool = futures.ThreadPoolExecutor(max_workers=max_workers) 118 | if request.config.getoption('grpc-fake'): 119 | server = FakeServer(pool) 120 | yield server 121 | else: 122 | server = grpc.server(pool, interceptors=grpc_interceptors) 123 | yield server 124 | pool.shutdown(wait=False) 125 | 126 | 127 | @pytest.fixture(scope='module') 128 | def grpc_server(_grpc_server, grpc_addr, grpc_add_to_server, grpc_servicer): 129 | grpc_add_to_server(grpc_servicer, _grpc_server) 130 | _grpc_server.add_insecure_port(grpc_addr) 131 | _grpc_server.start() 132 | yield _grpc_server 133 | _grpc_server.stop(grace=None) 134 | 135 | 136 | @pytest.fixture(scope='module') 137 | def grpc_create_channel(request, grpc_addr, grpc_server): 138 | def _create_channel(credentials=None, options=None): 139 | if request.config.getoption('grpc-fake'): 140 | return FakeChannel(grpc_server, credentials) 141 | if credentials is not None: 142 | return grpc.secure_channel(grpc_addr, credentials, options) 143 | return grpc.insecure_channel(grpc_addr, options) 144 | 145 | return _create_channel 146 | 147 | 148 | @pytest.fixture(scope='module') 149 | def grpc_channel(grpc_create_channel): 150 | with grpc_create_channel() as channel: 151 | yield channel 152 | 153 | 154 | @pytest.fixture(scope='module') 155 | def grpc_stub(grpc_stub_cls, grpc_channel): 156 | return grpc_stub_cls(grpc_channel) 157 | 158 | 159 | def pytest_addoption(parser): 160 | parser.addoption('--grpc-fake-server', action='store_true', dest='grpc-fake') 161 | parser.addoption('--grpc-max-workers', type=int, dest='grpc-max-workers', default=1) 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-grpc 2 | 3 | Write test for gRPC with pytest. 4 | 5 | 6 | ## Example 7 | 8 | See example dir and/or read 'usage'. 9 | 10 | ## Usage 11 | 12 | For example you have some proto file with rpc declaration. 13 | 14 | 15 | ```proto 16 | syntax = "proto3"; 17 | 18 | package test.v1; 19 | 20 | 21 | service EchoService { 22 | rpc handler(EchoRequest) returns (EchoResponse) { 23 | } 24 | } 25 | 26 | 27 | message EchoRequest { 28 | string name = 1; 29 | } 30 | 31 | message EchoResponse { 32 | string name = 1; 33 | } 34 | 35 | ``` 36 | 37 | After compile it with grpcio-tools, you get *_pb2.py and *_pb2_grpc.py files, now you can write your service. 38 | 39 | ```python 40 | from stub.test_pb2 import EchoRequest, EchoResponse 41 | from stub.test_pb2_grpc import EchoServiceServicer 42 | 43 | 44 | class Servicer(EchoServiceServicer): 45 | def handler(self, request: EchoRequest, context) -> EchoResponse: 46 | return EchoResponse(name=f'test-{request.name}') 47 | 48 | def error_handler(self, request: EchoRequest, context) -> EchoResponse: 49 | raise RuntimeError('Some error') 50 | ``` 51 | 52 | Point pytest with your stubs and service: 53 | 54 | ```python 55 | import pytest 56 | 57 | from stub.test_pb2 import EchoRequest 58 | 59 | 60 | @pytest.fixture(scope='module') 61 | def grpc_add_to_server(): 62 | from stub.test_pb2_grpc import add_EchoServiceServicer_to_server 63 | 64 | return add_EchoServiceServicer_to_server 65 | 66 | 67 | @pytest.fixture(scope='module') 68 | def grpc_servicer(): 69 | from servicer import Servicer 70 | 71 | return Servicer() 72 | 73 | 74 | @pytest.fixture(scope='module') 75 | def grpc_stub_cls(grpc_channel): 76 | from stub.test_pb2_grpc import EchoServiceStub 77 | 78 | return EchoServiceStub 79 | ``` 80 | 81 | Write little test: 82 | ```python 83 | 84 | def test_some(grpc_stub): 85 | request = EchoRequest() 86 | response = grpc_stub.handler(request) 87 | 88 | assert response.name == f'test-{request.name}' 89 | 90 | def test_example(grpc_stub): 91 | request = EchoRequest() 92 | response = grpc_stub.error_handler(request) 93 | 94 | assert response.name == f'test-{request.name}' 95 | ``` 96 | 97 | #### Testing secure server 98 | 99 | ```python 100 | from pathlib import Path 101 | import pytest 102 | import grpc 103 | 104 | @pytest.fixture(scope='module') 105 | def grpc_add_to_server(): 106 | from stub.test_pb2_grpc import add_EchoServiceServicer_to_server 107 | 108 | return add_EchoServiceServicer_to_server 109 | 110 | 111 | @pytest.fixture(scope='module') 112 | def grpc_servicer(): 113 | from servicer import Servicer 114 | 115 | return Servicer() 116 | 117 | 118 | @pytest.fixture(scope='module') 119 | def grpc_stub_cls(grpc_channel): 120 | from stub.test_pb2_grpc import EchoServiceStub 121 | 122 | return EchoServiceStub 123 | 124 | 125 | @pytest.fixture(scope='session') 126 | def my_ssl_key_path(): 127 | return Path('/path/to/key.pem') 128 | 129 | 130 | @pytest.fixture(scope='session') 131 | def my_ssl_cert_path(): 132 | return Path('/path/to/cert.pem') 133 | 134 | 135 | @pytest.fixture(scope='module') 136 | def grpc_server(_grpc_server, grpc_addr, my_ssl_key_path, my_ssl_cert_path): 137 | """ 138 | Overwrites default `grpc_server` fixture with ssl credentials 139 | """ 140 | credentials = grpc.ssl_server_credentials([ 141 | (my_ssl_key_path.read_bytes(), 142 | my_ssl_cert_path.read_bytes()) 143 | ]) 144 | 145 | _grpc_server.add_secure_port(grpc_addr, server_credentials=credentials) 146 | _grpc_server.start() 147 | yield _grpc_server 148 | _grpc_server.stop(grace=None) 149 | 150 | 151 | @pytest.fixture(scope='module') 152 | def my_channel_ssl_credentials(my_ssl_cert_path): 153 | # If we're using self-signed certificate it's necessarily to pass root certificate to channel 154 | return grpc.ssl_channel_credentials( 155 | root_certificates=my_ssl_cert_path.read_bytes() 156 | ) 157 | 158 | 159 | @pytest.fixture(scope='module') 160 | def grpc_channel(my_channel_ssl_credentials, create_channel): 161 | """ 162 | Overwrites default `grpc_channel` fixture with ssl credentials 163 | """ 164 | with create_channel(my_channel_ssl_credentials) as channel: 165 | yield channel 166 | 167 | 168 | @pytest.fixture(scope='module') 169 | def grpc_authorized_channel(my_channel_ssl_credentials, create_channel): 170 | """ 171 | Channel with authorization header passed 172 | """ 173 | grpc_channel_credentials = grpc.access_token_call_credentials("some_token") 174 | composite_credentials = grpc.composite_channel_credentials( 175 | my_channel_ssl_credentials, 176 | grpc_channel_credentials 177 | ) 178 | with create_channel(composite_credentials) as channel: 179 | yield channel 180 | 181 | 182 | @pytest.fixture(scope='module') 183 | def my_authorized_stub(grpc_stub_cls, grpc_channel): 184 | """ 185 | Stub with authorized channel 186 | """ 187 | return grpc_stub_cls(grpc_channel) 188 | 189 | ``` 190 | 191 | ## Run tests against real gRPC server 192 | Run tests against read grpc server worked in another thread: 193 | 194 | ```bash 195 | py.test 196 | ``` 197 | 198 | ``` 199 | cachedir: .pytest_cache 200 | plugins: grpc-0.0.0 201 | collected 2 items 202 | 203 | example/test_example.py::test_some PASSED 204 | example/test_example.py::test_example FAILED 205 | 206 | =================================== FAILURES ==================================== 207 | _________________________________ test_example __________________________________ 208 | 209 | grpc_stub = 210 | 211 | def test_example(grpc_stub): 212 | request = EchoRequest() 213 | > response = grpc_stub.error_handler(request) 214 | 215 | example/test_example.py:35: 216 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 217 | .env/lib/python3.7/site-packages/grpc/_channel.py:547: in __call__ 218 | return _end_unary_response_blocking(state, call, False, None) 219 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 220 | 221 | state = 222 | call = 223 | with_call = False, deadline = None 224 | 225 | def _end_unary_response_blocking(state, call, with_call, deadline): 226 | if state.code is grpc.StatusCode.OK: 227 | if with_call: 228 | rendezvous = _Rendezvous(state, call, None, deadline) 229 | return state.response, rendezvous 230 | else: 231 | return state.response 232 | else: 233 | > raise _Rendezvous(state, None, None, deadline) 234 | E grpc._channel._Rendezvous: <_Rendezvous of RPC that terminated with: 235 | E status = StatusCode.UNKNOWN 236 | E details = "Exception calling application: Some error" 237 | E debug_error_string = "{"created":"@1544451353.148337000","description":"Error received from peer","file":"src/core/lib/surface/call.cc","file_line":1036,"grpc_message":"Exception calling application: Some error","grpc_status":2}" 238 | E > 239 | 240 | .env/lib/python3.7/site-packages/grpc/_channel.py:466: _Rendezvous 241 | ------------------------------- Captured log call ------------------------------- 242 | _server.py 397 ERROR Exception calling application: Some error 243 | Traceback (most recent call last): 244 | File "pytest-grpc/.env/lib/python3.7/site-packages/grpc/_server.py", line 389, in _call_behavior 245 | return behavior(argument, context), True 246 | File "pytest-grpc/example/src/servicer.py", line 10, in error_handler 247 | raise RuntimeError('Some error') 248 | RuntimeError: Some error 249 | ================ 1 failed, 1 passed, 1 warnings in 0.16 seconds ================= 250 | 251 | ``` 252 | 253 | ## Run tests directly to python code 254 | Call handlers directly, with fake grpc internals: 255 | 256 | ```bash 257 | py.test --grpc-fake-server 258 | ``` 259 | 260 | In this case your get nice direct exceptions: 261 | 262 | ``` 263 | ============================= test session starts ============================= 264 | cachedir: .pytest_cache 265 | plugins: grpc-0.0.0 266 | collected 2 items 267 | 268 | example/test_example.py::test_some PASSED 269 | example/test_example.py::test_example FAILED 270 | 271 | ================================== FAILURES =================================== 272 | ________________________________ test_example _________________________________ 273 | 274 | grpc_stub = 275 | 276 | def test_example(grpc_stub): 277 | request = EchoRequest() 278 | > response = grpc_stub.error_handler(request) 279 | 280 | example/test_example.py:35: 281 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 282 | pytest_grpc/plugin.py:42: in fake_handler 283 | return real_method(request, context) 284 | _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 285 | 286 | self = , request = 287 | context = 288 | 289 | def error_handler(self, request: EchoRequest, context) -> EchoResponse: 290 | > raise RuntimeError('Some error') 291 | E RuntimeError: Some error 292 | 293 | example/src/servicer.py:10: RuntimeError 294 | =============== 1 failed, 1 passed, 1 warnings in 0.10 seconds ================ 295 | ``` 296 | 297 | ## Run the servicer on multiple threads 298 | The number of workers threads for gRPC can be specified in two ways: 299 | 300 | - add `--grpc-max-workers=` to the arguments 301 | - test modules can also use a `grpc_max_workers=` variable 302 | 303 | See `test_blocking` in example. 304 | --------------------------------------------------------------------------------