如何将参数传递给 Pytest 中的夹具函数?

2025-02-28 08:22:00
admin
原创
68
摘要:问题描述:我正在使用 py.test 测试一些包装在 Python 类 MyTester 中的 DLL 代码。为了验证目的,我需要在测试期间记录一些测试数据,然后进行更多处理。由于我有许多 test_... 文件,我想在大多数测试中重用测试器对象创建(MyTester 的实例)。由于测试器对象是获取 DLL ...

问题描述:

我正在使用 py.test 测试一些包装在 Python 类 MyTester 中的 DLL 代码。为了验证目的,我需要在测试期间记录一些测试数据,然后进行更多处理。由于我有许多 test_... 文件,我想在大多数测试中重用测试器对象创建(MyTester 的实例)。

由于测试器对象是获取 DLL 变量和函数引用的对象,因此我需要将 DLL 变量列表传递给每个测试文件的测试器对象(对于 test_... 文件,要记录的变量是相同的)。列表的内容用于记录指定的数据。

我的想法是以这样的方式实现的:

import pytest

class MyTester():
    def __init__(self, arg = ["var0", "var1"]):
        self.arg = arg
        # self.use_arg_to_init_logging_part()

    def dothis(self):
        print "this"

    def dothat(self):
        print "that"

# located in conftest.py (because other test will reuse it)

@pytest.fixture()
def tester(request):
    """ create tester object """
    # how to use the list below for arg?
    _tester = MyTester()
    return _tester

# located in test_...py

# @pytest.mark.usefixtures("tester") 
class TestIt():

    # def __init__(self):
    #     self.args_for_tester = ["var1", "var2"]
    #     # how to pass this list to the tester fixture?

    def test_tc1(self, tester):
       tester.dothis()
       assert 0 # for demo purpose

    def test_tc2(self, tester):
       tester.dothat()
       assert 0 # for demo purpose

是否可以这样实现或者是否有更优雅的方法?

通常我可以使用某种设置函数(xUnit 样式)对每种测试方法执行此操作。但我想获得某种重用。有人知道这是否可以通过装置实现吗?

我知道我可以做这样的事情:(来自文档)

@pytest.fixture(scope="module", params=["merlinux.eu", "mail.python.org"])

但是我需要直接在测试模块中进行参数化。
是否可以从测试模块访问夹具的 params 属性?


解决方案 1:

这实际上是通过间接参数化在 py.test 中原生支持的。

就你的情况来说,你应该:

@pytest.fixture
def tester(request):
    """Create tester object"""
    return MyTester(request.param)


class TestIt:
    @pytest.mark.parametrize('tester', [['var1', 'var2']], indirect=True)
    def test_tc1(self, tester):
       tester.dothis()
       assert 1

解决方案 2:

更新:由于这是该问题的可接受答案,并且有时仍会得到赞成,我应该添加更新。虽然我的原始答案(如下)是在旧版本的 pytest 中执行此操作的唯一方法,但其他人已经注意到pytest 现在支持夹具的间接参数化。例如,您可以执行如下操作(通过@imiric):

# test_parameterized_fixture.py
import pytest

class MyTester:
    def __init__(self, x):
        self.x = x

    def dothis(self):
        assert self.x

@pytest.fixture
def tester(request):
    """Create tester object"""
    return MyTester(request.param)


class TestIt:
    @pytest.mark.parametrize('tester', [True, False], indirect=['tester'])
    def test_tc1(self, tester):
       tester.dothis()
       assert 1
$ pytest -v test_parameterized_fixture.py
================================================================================= test session starts =================================================================================
platform cygwin -- Python 3.6.8, pytest-5.3.1, py-1.8.0, pluggy-0.13.1 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: .
collected 2 items

test_parameterized_fixture.py::TestIt::test_tc1[True] PASSED                                                                                                                    [ 50%]
test_parameterized_fixture.py::TestIt::test_tc1[False] FAILED

然而,尽管这种间接参数化形式是明确的,但正如@Yukihiko Shinoda指出的那样,它现在支持一种隐式间接参数化形式(尽管我在官方文档中找不到任何明显的参考):

# test_parameterized_fixture2.py
import pytest

class MyTester:
    def __init__(self, x):
        self.x = x

    def dothis(self):
        assert self.x

@pytest.fixture
def tester(tester_arg):
    """Create tester object"""
    return MyTester(tester_arg)


class TestIt:
    @pytest.mark.parametrize('tester_arg', [True, False])
    def test_tc1(self, tester):
       tester.dothis()
       assert 1
$ pytest -v test_parameterized_fixture2.py
================================================================================= test session starts =================================================================================
platform cygwin -- Python 3.6.8, pytest-5.3.1, py-1.8.0, pluggy-0.13.1 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: .
collected 2 items

test_parameterized_fixture2.py::TestIt::test_tc1[True] PASSED                                                                                                                   [ 50%]
test_parameterized_fixture2.py::TestIt::test_tc1[False] FAILED

我不清楚这种形式的语义到底是什么,但它似乎pytest.mark.parametrize认识到,虽然该test_tc1方法不采用名为的参数tester_arg,但tester它使用的装置却采用,因此它通过装置传递参数化参数tester


我遇到了类似的问题——我有一个名为的装置test_package,后来我希望能够在特定测试中运行该装置时向其传递一个可选参数。例如:

@pytest.fixture()
def test_package(request, version='1.0'):
    ...
    request.addfinalizer(fin)
    ...
    return package

(对于这些目的来说,装置的作用或返回的对象类型并不重要package)。

然后,最好以某种方式在测试函数中使用此装置,以便我还可以指定version该装置的参数以用于该测试。目前这是不可能的,但可能会成为一个很好的功能。

与此同时,可以很容易地让我的装置简单地返回一个函数,该函数完成装置以前所做的所有工作,但允许我指定version参数:

@pytest.fixture()
def test_package(request):
    def make_test_package(version='1.0'):
        ...
        request.addfinalizer(fin)
        ...
        return test_package

    return make_test_package

现在我可以在我的测试函数中使用它,例如:

def test_install_package(test_package):
    package = test_package(version='1.1')
    ...
    assert ...

等等。

OP 尝试的解决方案朝着正确的方向前进,并且正如@hpk42 的回答所暗示的那样,MyTester.__init__可以存储对请求的引用,例如:

class MyTester(object):
    def __init__(self, request, arg=["var0", "var1"]):
        self.request = request
        self.arg = arg
        # self.use_arg_to_init_logging_part()

    def dothis(self):
        print "this"

    def dothat(self):
        print "that"

然后使用它来实现如下的装置:

@pytest.fixture()
def tester(request):
    """ create tester object """
    # how to use the list below for arg?
    _tester = MyTester(request)
    return _tester

如果需要的话,MyTester可以稍微重构一下类,以便.args在创建之后可以更新它的属性,以调整各个测试的行为。

解决方案 3:

正如 @chilicheech 指出的那样,至少从 pytest 6.2(2020-12-13 发布)开始,官方就记录了这种方式:

  • 使用直接测试参数化覆盖装置 | 如何使用装置 — pytest 文档

  • pytest 文档版本 6.2

它似乎在最新版本的 pytest 中运行。

@pytest.fixture
def tester(tester_arg):
    """Create tester object"""
    return MyTester(tester_arg)


class TestIt:
    @pytest.mark.parametrize('tester_arg', [['var1', 'var2']])
    def test_tc1(self, tester):
       tester.dothis()
       assert 1

解决方案 4:

您还可以使用闭包,这将为您提供更全面的参数命名和控制。它们比隐式参数化request中使用的参数更“明确” :

@pytest.fixture
def tester():
    # Create a closure on the Tester object
    def _tester(first_param, second_param):
        # use the above params to mock and instantiate things
        return MyTester(first_param, second_param)
    
    # Pass this closure to the test
    yield _tester 


@pytest.mark.parametrize(['param_one', 'param_two'], [(1,2), (1000,2000)])
def test_tc1(tester, param_one, param_two):
    # run the closure now with the desired params
    my_tester = tester(param_one, param_two)
    # assert code here

我使用它来构建可配置的装置。

解决方案 5:

稍微改进一下imiric 的回答:解决这个问题的另一种优雅方法是创建“参数装置”。我个人更喜欢它而不是indirect的功能pytest。此功能可从 获得pytest_cases,最初的想法是由Sup3rGeo提出的。

import pytest
from pytest_cases import param_fixture

# create a single parameter fixture
var = param_fixture("var", [['var1', 'var2']], ids=str)

@pytest.fixture
def tester(var):
    """Create tester object"""
    return MyTester(var)

class TestIt:
    def test_tc1(self, tester):
       tester.dothis()
       assert 1

请注意,pytest-cases还提供了@fixture允许您直接在灯具上使用参数化标记的功能,而不必使用@pytest.fixture(params=...)

from pytest_cases import fixture, parametrize

@fixture
@parametrize("var", [['var1', 'var2']], ids=str)
def tester(var):
    """Create tester object"""
    return MyTester(var)

并且@parametrize_with_cases允许您从可能分组在类中甚至单独模块中的“case 函数”中获取参数。请参阅文档了解详情。顺便说一下,我是作者 ;)

解决方案 6:

您可以从装置函数(以及您的 Tester 类)访问请求模块/类/函数,请参阅从装置函数与请求测试上下文交互。因此,您可以在类或模块上声明一些参数,然后测试器装置就可以获取它。

解决方案 7:

我制作了一个有趣的装饰器,可以编写如下的装置:

@fixture_taking_arguments
def dog(request, /, name, age=69):
    return f"{name} the dog aged {age}"

这里,左边/是其他装置,右边是使用以下命令提供的参数:

@dog.arguments("Buddy", age=7)
def test_with_dog(dog):
    assert dog == "Buddy the dog aged 7"

这与函数参数的工作方式相同。如果不提供age参数,则使用默认参数69。如果不提供name或省略dog.arguments装饰器,则将获得常规参数TypeError: dog() missing 1 required positional argument: 'name'。如果您有另一个接受参数的装置name,则它不会与此冲突。

还支持异步装置。

此外,这还为您提供了一个很好的设置计划:

$ pytest test_dogs_and_owners.py --setup-plan

SETUP    F dog['Buddy', age=7]
...
SETUP    F dog['Champion']
SETUP    F owner (fixtures used: dog)['John Travolta']

完整示例:

from plugin import fixture_taking_arguments

@fixture_taking_arguments
def dog(request, /, name, age=69):
    return f"{name} the dog aged {age}"


@fixture_taking_arguments
def owner(request, dog, /, name="John Doe"):
    yield f"{name}, owner of {dog}"


@dog.arguments("Buddy", age=7)
def test_with_dog(dog):
    assert dog == "Buddy the dog aged 7"


@dog.arguments("Champion")
class TestChampion:
    def test_with_dog(self, dog):
        assert dog == "Champion the dog aged 69"

    def test_with_default_owner(self, owner, dog):
        assert owner == "John Doe, owner of Champion the dog aged 69"
        assert dog == "Champion the dog aged 69"

    @owner.arguments("John Travolta")
    def test_with_named_owner(self, owner):
        assert owner == "John Travolta, owner of Champion the dog aged 69"

装饰器的代码:

import pytest
from dataclasses import dataclass
from functools import wraps
from inspect import signature, Parameter, isgeneratorfunction, iscoroutinefunction, isasyncgenfunction
from itertools import zip_longest, chain


_NOTHING = object()


def _omittable_parentheses_decorator(decorator):
    @wraps(decorator)
    def wrapper(*args, **kwargs):
        if not kwargs and len(args) == 1 and callable(args[0]):
            return decorator()(args[0])
        else:
            return decorator(*args, **kwargs)
    return wrapper


@dataclass
class _ArgsKwargs:
    args: ...
    kwargs: ...

    def __repr__(self):
        return ", ".join(chain(
               (repr(v) for v in self.args), 
               (f"{k}={v!r}" for k, v in self.kwargs.items())))


def _flatten_arguments(sig, args, kwargs):
    assert len(sig.parameters) == len(args) + len(kwargs)
    for name, arg in zip_longest(sig.parameters, args, fillvalue=_NOTHING):
        yield arg if arg is not _NOTHING else kwargs[name]


def _get_actual_args_kwargs(sig, args, kwargs):
    request = kwargs["request"]
    try:
        request_args, request_kwargs = request.param.args, request.param.kwargs
    except AttributeError:
        request_args, request_kwargs = (), {}
    return tuple(_flatten_arguments(sig, args, kwargs)) + request_args, request_kwargs


@_omittable_parentheses_decorator
def fixture_taking_arguments(*pytest_fixture_args, **pytest_fixture_kwargs):
    def decorator(func):
        original_signature = signature(func)

        def new_parameters():
            for param in original_signature.parameters.values():
                if param.kind == Parameter.POSITIONAL_ONLY:
                    yield param.replace(kind=Parameter.POSITIONAL_OR_KEYWORD)

        new_signature = original_signature.replace(parameters=list(new_parameters()))

        if "request" not in new_signature.parameters:
            raise AttributeError("Target function must have positional-only argument `request`")

        is_async_generator = isasyncgenfunction(func)
        is_async = is_async_generator or iscoroutinefunction(func)
        is_generator = isgeneratorfunction(func)

        if is_async:
            @wraps(func)
            async def wrapper(*args, **kwargs):
                args, kwargs = _get_actual_args_kwargs(new_signature, args, kwargs)
                if is_async_generator:
                    async for result in func(*args, **kwargs):
                        yield result
                else:
                    yield await func(*args, **kwargs)
        else:
            @wraps(func)
            def wrapper(*args, **kwargs):
                args, kwargs = _get_actual_args_kwargs(new_signature, args, kwargs)
                if is_generator:
                    yield from func(*args, **kwargs)
                else:
                    yield func(*args, **kwargs)

        wrapper.__signature__ = new_signature
        fixture = pytest.fixture(*pytest_fixture_args, **pytest_fixture_kwargs)(wrapper)
        fixture_name = pytest_fixture_kwargs.get("name", fixture.__name__)

        def parametrizer(*args, **kwargs):
            return pytest.mark.parametrize(fixture_name, [_ArgsKwargs(args, kwargs)], indirect=True)

        fixture.arguments = parametrizer

        return fixture
    return decorator

解决方案 8:

另一种方法是使用请求对象来访问测试函数所定义的模块或类中定义的变量。

@pytest.mark.parametrize()这样,如果您想为类/模块的所有测试函数传递相同的变量,则不必在测试类的每个函数上重用装饰器。

类变量的示例:

@pytest.fixture
def tester(request):
    """Create tester object"""
    return MyTester(request.cls.tester_args)


class TestIt:
    tester_args = ['var1', 'var2']

    def test_tc1(self, tester):
       tester.dothis()

    def test_tc2(self, tester):
       tester.dothat()

这样,testertest_tc1 和 test_tc2 的对象都将使用参数进行初始化tester_args

您还可以使用:

  • request.function访问 test_tc1 函数,

  • request.instance访问 TestIt 类实例,

  • request.module访问模块 TestIt 定义在

  • 等等(参考request文档)

解决方案 9:

另一种方法是使用自定义标记。它在代码中看起来比参数化更好,不会反映在测试名称中,也是可选的(如果不存在这样的标记,可以通过引发失败来定义为非可选)

例如:

@pytest.fixture
def loaded_dll(request):
    dll_file = None
    for mark in request.node.iter_markers("dll_file"):
        if mark.args:
            if dll_file is not None:
                pytest.fail("Only one dll_file can be mentioned in marks")
            dll_file = mark.args[0]
    if dll_file is None:
        pytest.fail("dll_file is a required mark")
    return some_dll_load(dll_file)

@pytest.mark.dll_file("this.dll")
def test_this_dll(loaded_dll):
    ...

当我需要一个模拟 ssh 客户端的装置并想要测试不同的可能输出时,我将它用于我的测试,我可以使用标记来决定每个测试的输出。

请注意,如果它是供个人使用,则不需要未通过测试的故障保存机制。

解决方案 10:

Ori Markovitch 的回答无疑是最适合我的情况的技巧。唯一的缺点是 Fixture 不会显示为参数化测试用例,但在大多数情况下我认为这没问题。就我而言,我希望测试用例上方的所有测试参数都包含在这个技巧中。我只想指出,使用 Fixture 标记技巧,您既可以传递 args 和 kwargs,也可以指定拆分为不同标记的多个参数。在错误处理方面,您还需要做一些事情,但对于我制作的示例,我保持简单。

我在这里举了几个例子来展示如何利用这一点:

import pytest
from _pytest.mark import Mark


def get_marker(request, name) -> Mark:
    markers = list(request.node.iter_markers(name))
    if len(markers) > 1:
        pytest.fail(f"Found multiple markers for {name}")
    return markers[0]


class FixMark:
    def __init__(self, var1, var2):
        self.var1 = var1
        self.var2 = var2


@pytest.fixture
def var_test(request):
    mark1 = get_marker(request, "var_test_var1")
    mark2 = get_marker(request, "var_test_var2")
    var1 = mark1.kwargs["var1"] if len(mark1.kwargs) else mark1.args[0]
    var2 = mark2.kwargs["var2"] if len(mark2.kwargs) else mark2.args[0]
    yield FixMark(var1, var2)


@pytest.fixture
def var_combined_test(request):
    mark = get_marker(request, "var_combined")
    var1 = mark.kwargs["var1"] if mark.kwargs.get("var1") else mark.args[0]
    var2 = mark.kwargs["var2"] if mark.kwargs.get("var2") else mark.args[1]
    yield FixMark(var1, var2)


@pytest.mark.var_test_var1("ABC")
@pytest.mark.var_test_var2("DEF")
def test_var_test_args(var_test):
    assert var_test.var1 == "ABC"
    assert var_test.var2 == "DEF"


@pytest.mark.var_test_var1(var1="ABC")
@pytest.mark.var_test_var2(var2="DEF")
def test_var_test_kwargs(var_test):
    assert var_test.var1 == "ABC"
    assert var_test.var2 == "DEF"


@pytest.mark.var_combined("ABC", "DEF")
def test_vars_combined_args(var_combined_test):
    assert var_combined_test.var1 == "ABC"
    assert var_combined_test.var2 == "DEF"


@pytest.mark.var_combined(var1="ABC", var2="DEF")
def test_vars_combined_kwargs(var_combined_test):
    assert var_combined_test.var1 == "ABC"
    assert var_combined_test.var2 == "DEF"

解决方案 11:

Fixture 的工作原理类似于装饰器。我认为它更简单、更清晰。你也可以使用它

在 conftest.py 中

@pytest.fixture
def tester():
    def wrapper(arg):
        _tester = MyTester(arg)
        return _tester
    return wrapper

在 test.py 中

class TestIt():

   def test_tc1(self, tester, arg):  # test function takes fixture and arg
   mock_tester = tester(arg)  # mock_tester just an instance of MyTester
   mock_tester.dothis()  # so you get instance with initialized arg
   assert 0 # for demo purpose

解决方案 12:

以下是使用装置中的命名参数执行此操作的方法,并附带 mypy 类型支持。请注意tuple参数化装饰器中使用的值,它允许您分离参数 - 为参数化测试用例向 param 数组中添加任意数量的元组。

import os
from typing import Literal

import pytest


@pytest.fixture(autouse=False, scope="function")
def set_env(key: str, value: str):
    """Set an environment variable temporarily, backing up and restoring the original value if it was set before the test was run."""

    orig_value = os.getenv(key)

    os.environ[key] = value
    yield

    if orig_value is not None:
        os.environ[key] = orig_value
    else:
        del os.environ[key]


@pytest.mark.parametrize("key, value", [("TEST_ENV", "env_value")])
def test_get_env(set_env: Literal["TEST_ENV"] | Literal["env_value"]):
    assert os.getenv("TEST_ENV") == "env_value"

您还可以附加额外的标准参数化装置或变量 - 并且 pytest 知道(神奇地?)计算装置函数中预期参数的数量,并将其余的参数作为标准参数传递给测试:

@pytest.mark.parametrize(
    "key, value, default, expected, expected_default",
    [
        ("TEST_ENV", "env_value", None, "env_value", None),
        ("TEST_ENV", "env_value", "default_value", "env_value", "default_value"),
    ],
)
def test_get_env(
    set_env: None,
    default: str | None,
    expected: str,
    expected_default: str | None,
):
    assert os.getenv("TEST_ENV") == expected
    assert os.environ.get("TEST_ENV") == expected
    assert os.environ.get("NOT_SET_ENV", default) == expected_default

解决方案 13:

例如,使用@pytest.fixture(params=(...)),您可以将AppleOrangeBanana传递给fruits()夹具本身,然后传递给 ,test()如下所示:

import pytest

@pytest.fixture(params=("Apple", "Orange", "Banana"))
def fruits(request):
    print("fruits", request.param) # 1st
    return request.param

def test(fruits):
    print("test", fruits) # 2nd
    assert True

输出:

$ pytest -q -rP
...                              [100%]
=============== PASSES ================ 
_____________ test[Apple] _____________ 
-------- Captured stdout setup -------- 
fruits Apple
-------- Captured stdout call --------- 
test Apple
____________ test[Orange] _____________ 
-------- Captured stdout setup -------- 
fruits Orange
-------- Captured stdout call --------- 
test Orange
____________ test[Banana] _____________ 
-------- Captured stdout setup -------- 
fruits Banana
-------- Captured stdout call --------- 
test Banana
3 passed in 0.11s

举例来说,你可以向addition()具有嵌套函数的 Fixture传递 2 个参数core(),如下所示:

import pytest

@pytest.fixture
def addition():
    def core(num1, num2):
        return num1 + num2
    return core

def test(request, addition):
    print(addition(2, 3))
    print(request.getfixturevalue("addition")(6, 8))
    assert True

输出:

$ pytest -q -rP
.                                [100%]
=============== PASSES ================ 
________________ test _________________ 
-------- Captured stdout call --------- 
5
14
1 passed in 0.10s

解决方案 14:

在我的例子中,我需要在夹具拆卸时根据其 ID 删除请求。问题是只有在测试期间我才能获取我的请求 ID。所以我需要一个夹具,它可以在测试中间接受一个参数,并在测试后删除我的请求。我想出了这样的东西:

@pytest.fixture()
def delete_request():
    # setUp:
    local_request_id = None

    def get_request_id_from_test(request_id: int):
        nonlocal local_request_id
        local_request_id = request_id

    yield get_request_id_from_test

    # tearDown:
    api.delete_request(local_request_id)

def test_create_request(delete_request):
    # Array
    ...

    # Act
    ... # here i get request id after creating a request
    delete_request(request_id)
   
    ...

此决定不需要任何标记和参数化

相关推荐
  政府信创国产化的10大政策解读一、信创国产化的背景与意义信创国产化,即信息技术应用创新国产化,是当前中国信息技术领域的一个重要发展方向。其核心在于通过自主研发和创新,实现信息技术应用的自主可控,减少对外部技术的依赖,并规避潜在的技术制裁和风险。随着全球信息技术竞争的加剧,以及某些国家对中国在科技领域的打压,信创国产化显...
工程项目管理   2941  
  为什么项目管理通常仍然耗时且低效?您是否还在反复更新电子表格、淹没在便利贴中并参加每周更新会议?这确实是耗费时间和精力。借助软件工具的帮助,您可以一目了然地全面了解您的项目。如今,国内外有足够多优秀的项目管理软件可以帮助您掌控每个项目。什么是项目管理软件?项目管理软件是广泛行业用于项目规划、资源分配和调度的软件。它使项...
项目管理软件   1803  
  PLM(产品生命周期管理)系统在企业的产品研发、生产与管理过程中扮演着至关重要的角色。然而,在实际运行中,资源冲突是经常会遇到的难题。资源冲突可能导致项目进度延迟、成本增加以及产品质量下降等一系列问题,严重影响企业的效益与竞争力。因此,如何有效应对PLM系统中的资源冲突,成为众多企业关注的焦点。接下来,我们将详细探讨5...
plm项目管理系统   31  
  敏捷项目管理与产品生命周期管理(PLM)的融合,正成为企业在复杂多变的市场环境中提升研发效率、增强竞争力的关键举措。随着技术的飞速发展和市场需求的快速更迭,传统的研发流程面临着诸多挑战,而将敏捷项目管理理念融入PLM,有望在2025年实现研发流程的深度优化,为企业创造更大的价值。理解敏捷项目管理与PLM的核心概念敏捷项...
plm项目   31  
  模块化设计在现代产品开发中扮演着至关重要的角色,它能够提升产品开发效率、降低成本、增强产品的可维护性与可扩展性。而产品生命周期管理(PLM)系统作为整合产品全生命周期信息的关键平台,对模块化设计有着强大的支持能力。随着技术的不断发展,到 2025 年,PLM 系统在支持模块化设计方面将有一系列令人瞩目的技术实践。数字化...
plm软件   28  
热门文章
项目管理软件有哪些?
曾咪二维码

扫码咨询,免费领取项目管理大礼包!

云禅道AD
禅道项目管理软件

云端的项目管理软件

尊享禅道项目软件收费版功能

无需维护,随时随地协同办公

内置subversion和git源码管理

每天备份,随时转为私有部署

免费试用