$30 off During Our Annual Pro Sale. View Details »

Lessons in Testing - DjangoCon 2012

David Cramer
September 05, 2012

Lessons in Testing - DjangoCon 2012

David Cramer

September 05, 2012
Tweet

More Decks by David Cramer

Other Decks in Technology

Transcript

  1. Lessons in Testing
    David Cramer
    twitter.com/zeeg
    Wednesday, September 5, 12

    View Slide

  2. Who am I?
    Wednesday, September 5, 12

    View Slide

  3. I lead Infrastructure at DISQUS
    Wednesday, September 5, 12

    View Slide

  4. Wednesday, September 5, 12

    View Slide

  5. DISQUS sees a lot of tra c
    Google Analytics: Aug 2 2012 - Sep 1 2012
    Wednesday, September 5, 12

    View Slide

  6. Lots of Django
    (and things like Flask)
    Wednesday, September 5, 12

    View Slide

  7. Growing Startup
    (less than 20 engineers)
    Wednesday, September 5, 12

    View Slide

  8. Wednesday, September 5, 12

    View Slide

  9. We are also terrible at testing
    (but we try not to be)
    Wednesday, September 5, 12

    View Slide

  10. This presentation is about
    Lessons and Aspirations
    Wednesday, September 5, 12

    View Slide

  11. Lessons Mostly Learned
    Wednesday, September 5, 12

    View Slide

  12. Lesson #1:
    No Want Likes Writing Tests
    Wednesday, September 5, 12

    View Slide

  13. Time Consuming to Write
    Wednesday, September 5, 12

    View Slide

  14. Simple Feature: 10 lines of code
    from django.db import transaction
    class AutocommitMiddleware(object):
    def process_view(self, request, *a, **kw):
    if request.method == 'POST':
    enter_transaction_management(True)
    def process_response(self, request, response):
    leave_transaction_management()
    return response
    def process_exception(self, *a, **kw):
    leave_transaction_management()
    Wednesday, September 5, 12

    View Slide

  15. with precision testing
    class AutocommitMiddlewareTest:
    @mock.patch('enter_transaction_management')
    def test_something_precise(self, etm):
    request = WsgiHttpRequest()
    request.method = 'POST'
    middleware = AutocommitMiddleware()
    middleware.process_view(request)
    etm.assert_called_once_with(True)
    Wednesday, September 5, 12

    View Slide

  16. turns into 36 lines of tests
    import mock
    from mock_django.http import WsgiHttpRequest
    from django.http import HttpResponse
    from disqus.middleware.autocommit import AutocommitMiddleware
    from disqus.tests import DisqusTest
    class AutocommitMiddlewareTest(DisqusTest):
    @mock.patch('disqus.middleware.autocommit.transaction.enter_transaction_management')
    def test_changes_to_transactional_on_post(self, enter_transaction_management):
    request = WsgiHttpRequest()
    request.method = 'POST'
    middleware = AutocommitMiddleware()
    middleware.process_view(request, lambda: "", [], {})
    enter_transaction_management.assert_called_once_with(True, using='default')
    @mock.patch('disqus.middleware.autocommit.transaction.enter_transaction_management')
    def test_leaves_autocommit_on_get(self, enter_transaction_management):
    request = WsgiHttpRequest()
    request.method = 'GET'
    middleware = AutocommitMiddleware()
    middleware.process_view(request, lambda: "", [], {})
    self.assertFalse(enter_transaction_management.called)
    @mock.patch('disqus.middleware.autocommit.transaction.leave_transaction_management')
    def test_changes_to_autocommit_on_response(self, leave_transaction_management):
    request = WsgiHttpRequest()
    request.method = 'GET'
    response = HttpResponse()
    middleware = AutocommitMiddleware()
    middleware.process_response(request, response)
    leave_transaction_management.assert_called_once_with(using='default')
    @mock.patch('disqus.middleware.autocommit.transaction.leave_transaction_management')
    def test_changes_to_autocommit_on_exception(self, leave_transaction_management):
    request = WsgiHttpRequest()
    request.method = 'GET'
    exception = Exception()
    middleware = AutocommitMiddleware()
    middleware.process_exception(request, exception)
    leave_transaction_management.assert_called_once_with(using='default')
    Wednesday, September 5, 12

    View Slide

  17. ~50% of the time was writing tests
    (often more)
    Wednesday, September 5, 12

    View Slide

  18. Legacy (Untested) Code is Expensive
    Wednesday, September 5, 12

    View Slide

  19. Add Tests for New Code
    Wednesday, September 5, 12

    View Slide

  20. Add Tests for Regressions
    Wednesday, September 5, 12

    View Slide

  21. You Pick: Fast or Accurate
    (you only get to have one)
    Wednesday, September 5, 12

    View Slide

  22. Spend more time writing tests
    or more time running them
    Wednesday, September 5, 12

    View Slide

  23. You Can Buy Faster Hardware
    Wednesday, September 5, 12

    View Slide

  24. class AutocommitMiddlewareTest:
    @mock.patch('enter_transaction_management')
    def test_something_precise(self, etm):
    request = WsgiHttpRequest()
    request.method = 'POST'
    middleware = AutocommitMiddleware()
    middleware.process_view(request)
    etm.assert_called_once_with(True)
    Wednesday, September 5, 12

    View Slide

  25. class AutocommitMiddlewareTest:
    def setUp(self):
    assert 'AutocommitMiddleware' in INSTALLED_APPS
    def test_something_useful(self):
    with self.assertInTransaction():
    # use Django’s integrated test client
    self.client.post('/')
    Wednesday, September 5, 12

    View Slide

  26. Higher Level Tests are Slower
    But More “Correct”
    Wednesday, September 5, 12

    View Slide

  27. Unit vs Integration Tests
    Wednesday, September 5, 12

    View Slide

  28. @mock.patch('enter_transaction_management')
    def test_etm_called(self, etm):
    request = WsgiHttpRequest(method='POST')
    middleware = AutocommitMiddleware()
    middleware.process_view(request)
    etm.assert_called_once_with(True)
    Wednesday, September 5, 12

    View Slide

  29. with self.assertInTransaction():
    # use Django’s integrated test client
    self.client.post('/')
    Wednesday, September 5, 12

    View Slide

  30. Which is better?
    (you should have both)
    Wednesday, September 5, 12

    View Slide

  31. Mocking is Fragile
    Wednesday, September 5, 12

    View Slide

  32. # what happens if the path changes?
    @mock.patch('enter_transaction_management')
    def test_something_useful(self, etm):
    Wednesday, September 5, 12

    View Slide

  33. # what if we change the arguments passed?
    etm.assert_called_once_with(True)
    Wednesday, September 5, 12

    View Slide

  34. Useful for Testing External Services
    (Twitter, Facebook, Disqus)
    Wednesday, September 5, 12

    View Slide

  35. Capture Live Data for Mocking
    Wednesday, September 5, 12

    View Slide

  36. class TwitterTest:
    @mock.patch('twitter.Twitter.getresponse')
    def test_something_useful(self, gr):
    gr.return_value = TWITTER_RESPONSE
    res = Twitter().foo()
    assert res == [1, 2, 3]
    Wednesday, September 5, 12

    View Slide

  37. Assume APIs Don’t Change
    (it’s mostly true)
    Wednesday, September 5, 12

    View Slide

  38. Test The Lifecycle of Requests
    Wednesday, September 5, 12

    View Slide

  39. Selenium Kind of Works
    (but it’s extremely brittle)
    Wednesday, September 5, 12

    View Slide

  40. Lesson #2:
    Don’t Admit Defeat
    Wednesday, September 5, 12

    View Slide

  41. Start with a Goal
    Wednesday, September 5, 12

    View Slide

  42. Goal #1:
    Write Testable Code
    Wednesday, September 5, 12

    View Slide

  43. A lot of Code is Hard to Test
    (but it doesn’t have to be)
    Wednesday, September 5, 12

    View Slide

  44. def static_media(request, path):
    """
    Serve static files below a given point in the directory structure.
    """
    from django.utils.http import http_date
    from django.views.static import was_modified_since
    import mimetypes
    import os.path
    import posixpath
    import stat
    import urllib
    document_root = os.path.join(settings.STATIC_ROOT)
    path = posixpath.normpath(urllib.unquote(path))
    path = path.lstrip('/')
    newpath = ''
    for part in path.split('/'):
    if not part:
    # Strip empty path components.
    continue
    drive, part = os.path.splitdrive(part)
    head, part = os.path.split(part)
    if part in (os.curdir, os.pardir):
    # Strip '.' and '..' in path.
    continue
    newpath = os.path.join(newpath, part).replace('\\', '/')
    if newpath and path != newpath:
    return HttpResponseRedirect(newpath)
    fullpath = os.path.join(document_root, newpath)
    if os.path.isdir(fullpath):
    raise Http404("Directory indexes are not allowed here.")
    if not os.path.exists(fullpath):
    raise Http404('"%s" does not exist' % fullpath)
    # Respect the If-Modified-Since header.
    statobj = os.stat(fullpath)
    mimetype = mimetypes.guess_type(fullpath)[0] or 'application/octet-stream'
    if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'),
    statobj[stat.ST_MTIME], statobj[stat.ST_SIZE]):
    return HttpResponseNotModified(mimetype=mimetype)
    contents = open(fullpath, 'rb').read()
    response = HttpResponse(contents, mimetype=mimetype)
    response["Last-Modified"] = http_date(statobj[stat.ST_MTIME])
    response["Content-Length"] = len(contents)
    return response
    Wednesday, September 5, 12

    View Slide

  45. Break Up Your Code
    Wednesday, September 5, 12

    View Slide

  46. def static_media(request, path):
    """
    Serve static files below a given point in the directory structure.
    """
    from django.utils.http import http_date
    from django.views.static import was_modified_since
    import mimetypes
    import os.path
    import posixpath
    import stat
    import urllib
    document_root = os.path.join(settings.STATIC_ROOT)
    path = posixpath.normpath(urllib.unquote(path))
    path = path.lstrip('/')
    newpath = ''
    for part in path.split('/'):
    if not part:
    # Strip empty path components.
    continue
    drive, part = os.path.splitdrive(part)
    head, part = os.path.split(part)
    if part in (os.curdir, os.pardir):
    # Strip '.' and '..' in path.
    continue
    newpath = os.path.join(newpath, part).replace('\\', '/')
    if newpath and path != newpath:
    return HttpResponseRedirect(newpath)
    fullpath = os.path.join(document_root, newpath)
    if os.path.isdir(fullpath):
    raise Http404("Directory indexes are not allowed here.")
    if not os.path.exists(fullpath):
    raise Http404('"%s" does not exist' % fullpath)
    # Respect the If-Modified-Since header.
    statobj = os.stat(fullpath)
    mimetype = mimetypes.guess_type(fullpath)[0] or 'application/octet-stream'
    if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'),
    statobj[stat.ST_MTIME], statobj[stat.ST_SIZE]):
    return HttpResponseNotModified(mimetype=mimetype)
    contents = open(fullpath, 'rb').read()
    response = HttpResponse(contents, mimetype=mimetype)
    response["Last-Modified"] = http_date(statobj[stat.ST_MTIME])
    response["Content-Length"] = len(contents)
    return response
    Wednesday, September 5, 12

    View Slide

  47. Goal #2:
    Start Writing Tests
    Wednesday, September 5, 12

    View Slide

  48. Make Testing Easy
    Wednesday, September 5, 12

    View Slide

  49. class AutocommitMiddlewareTest(DisqusTest):
    def test_changes_to_transactional_on_post(self):
    # adding a simple assert helper makes this
    # test much easier to write
    with self.assertInTransaction():
    # changing this to an integration test
    # doesn’t require an understanding of
    # Django middleware internals
    self.client.post('/')
    Wednesday, September 5, 12

    View Slide

  50. Create Structure
    Wednesday, September 5, 12

    View Slide

  51. tests
    !"" __init__.py
    !"" integration
    # !"" __init__.py
    # !"" disqus
    # # !"" __init__.py
    # # !"" db
    # # # !"" __init__.py
    # # # !"" annotations
    # # # # !"" __init__.py
    # # # # !"" models.py
    # # # # %"" tests.py
    # # # !"" cachedbymodel
    # # # # !"" __init__.py
    # # # # !"" models.py
    # # # # %"" tests.py
    # # # !"" disqusmanager
    # # # # !"" __init__.py
    # # # # !"" models.py
    # # # # %"" tests.py
    # # # !"" iterablequeryset
    # # # # !"" __init__.py
    # # # # %"" tests.py
    Wednesday, September 5, 12

    View Slide

  52. Document Best Practices
    Wednesday, September 5, 12

    View Slide

  53. Wednesday, September 5, 12

    View Slide

  54. Prevent Mistakes
    Wednesday, September 5, 12

    View Slide

  55. NOSE_PLUGINS = [
    'disqus.tests.plugins.redis.RedisPlugin',
    'disqus.tests.plugins.sockets.ErroringSocketWhitelistPlugin',
    'disqus.tests.plugins.impermium.MockImpermiumPlugin',
    'disqus.tests.plugins.mail.FixMailHostnamePlugin',
    'disqus.tests.plugins.compat.UnitTestPlugin',
    'disqus.tests.plugins.tags.TagSelector',
    'disqus.tests.plugins.timing.TimingPlugin',
    'disqus.tests.plugins.themes.DefaultThemePlugin',
    'disqus.tests.plugins.apps.AddInstalledApps',
    'disqus.tests.plugins.selenium.ScreenshotFailurePlugin',
    'disqus.tests.plugins.tasks.SeppukuTasksPlugin',
    'disqus.tests.plugins.django.DjangoSetUpPlugin',
    ]
    Wednesday, September 5, 12

    View Slide

  56. Goal #3:
    Continuously Run Tests
    (via automation)
    Wednesday, September 5, 12

    View Slide

  57. Wednesday, September 5, 12

    View Slide

  58. Drive It Into Your Culture
    Wednesday, September 5, 12

    View Slide

  59. Development Workflow
    Review
    Integration
    Deploy
    Failed Build
    Reporting
    Rollback
    Commit
    Wednesday, September 5, 12

    View Slide

  60. Use Code Review
    Wednesday, September 5, 12

    View Slide

  61. Wednesday, September 5, 12

    View Slide

  62. Test Throughout The Process
    Wednesday, September 5, 12

    View Slide

  63. Test Lifecycle
    Pre-Review Tests
    CI
    Smoke Tests
    Failed Build
    Commit
    Wednesday, September 5, 12

    View Slide

  64. Tools of the Trade
    Wednesday, September 5, 12

    View Slide

  65. Use the Right Tools
    (if they don’t exist, build them)
    Wednesday, September 5, 12

    View Slide

  66. Nose
    A Better Test Runner
    nose.readthedocs.org
    Wednesday, September 5, 12

    View Slide

  67. Plugins for (Almost) Everything
    Wednesday, September 5, 12

    View Slide

  68. # dump XML results
    nosetests --with-xunit
    # drop into a PDB on errors and failures
    nosetests --pdb --pdb-failure
    Wednesday, September 5, 12

    View Slide

  69. Coverage.py
    Find Untested Code
    nedbatchelder.com/code/coverage
    Wednesday, September 5, 12

    View Slide

  70. Wednesday, September 5, 12

    View Slide

  71. Wednesday, September 5, 12

    View Slide

  72. Sentry
    Exception Reporting
    github.com/getsentry/sentry
    Wednesday, September 5, 12

    View Slide

  73. Because Tests Aren’t Enough
    Wednesday, September 5, 12

    View Slide

  74. Wednesday, September 5, 12

    View Slide

  75. Wednesday, September 5, 12

    View Slide

  76. Simple Continuous Integration
    jenkins-ci.org
    Wednesday, September 5, 12

    View Slide

  77. A Workflow With CI
    Review
    Integration
    Deploy
    Failed Build
    Reporting
    Rollback
    Commit
    Wednesday, September 5, 12

    View Slide

  78. Selenium
    api/*
    Unit
    Integration
    disqus-web
    admin/* analytics/*
    JavaScript Package
    Simple Build Distribution
    embed/*
    forums/* db/*
    Performance
    Wednesday, September 5, 12

    View Slide

  79. Code Review Done Right
    phabricator.org
    Wednesday, September 5, 12

    View Slide

  80. Highly Integrated Review Process
    Wednesday, September 5, 12

    View Slide

  81. Wednesday, September 5, 12

    View Slide

  82. Wednesday, September 5, 12

    View Slide

  83. Gargoyle
    Selectively Enable Features in Code
    github.com/disqus/gargoyle
    Wednesday, September 5, 12

    View Slide

  84. Wednesday, September 5, 12

    View Slide

  85. from gargoyle import gargoyle
    def my_view(request):
    if gargoyle.is_active('my_awesome_switch',
    request):
    # do something awesome
    else:
    # do something less awesome
    Wednesday, September 5, 12

    View Slide

  86. Zumanji
    Performance Testing
    github.com/disqus/zumanji
    Wednesday, September 5, 12

    View Slide

  87. Wednesday, September 5, 12

    View Slide

  88. Wednesday, September 5, 12

    View Slide

  89. Still a W.I.P.
    Wednesday, September 5, 12

    View Slide

  90. Takeaways
    Wednesday, September 5, 12

    View Slide

  91. Your Needs Will Vary
    Wednesday, September 5, 12

    View Slide

  92. The Process is Evolving
    Wednesday, September 5, 12

    View Slide

  93. Culture is Key
    Wednesday, September 5, 12

    View Slide

  94. Go Write Tests!
    Wednesday, September 5, 12

    View Slide

  95. Questions?
    Wednesday, September 5, 12

    View Slide

  96. Obligatory Slide Full of Useful Links
    ๏ Sentry (exception reporting)
    github.com/dcramer/sentry
    ๏ Jenkins CI (continuous integration)
    jenkins-ci.org
    ๏ Phabricator (code review)
    phabricator.org
    ๏ Gargoyle (feature switches)
    github.com/disqus/gargoyle
    ๏ Zumanji (performance tests)
    github.com/disqus/zumanji
    ๏ Mock
    mock.readthedocs.org
    ๏ Nose (test runner)
    nose.readthedocs.org
    ๏ nose-quickunit (test discovery)
    github.com/dcramer/nose-quickunit
    ๏ django-nose (Django integration)
    github.com/jbalogh/django-nose
    ๏ Coverage.py (code coverage)
    nedbatchelder.com/code/coverage/
    Wednesday, September 5, 12

    View Slide