From 4a0dd27d25e7a7487c5a82765f4515d0f2d6f2eb Mon Sep 17 00:00:00 2001
From: Alan Hamlett <alan.hamlett@gmail.com>
Date: Wed, 8 Nov 2017 22:54:33 -0800
Subject: [PATCH] enable bulk api endpoint

---
 ...fig_file => configs_test_good_config_file} |   2 +-
 tests/test_arguments.py                       | 588 ++++++++----
 tests/test_configs.py                         | 304 +++----
 tests/test_dependencies.py                    | 848 ++++--------------
 tests/test_languages.py                       | 310 +++----
 tests/test_logging.py                         |  21 +-
 tests/test_main.py                            | 466 ++++------
 tests/test_offlinequeue.py                    |  91 +-
 tests/test_project.py                         | 260 +++---
 tests/test_proxy.py                           |   4 +-
 tests/test_session_cache.py                   |   8 +-
 tests/utils.py                                |  93 +-
 wakatime/api.py                               | 177 ++++
 wakatime/arguments.py                         |   2 +-
 wakatime/compat.py                            |   7 +
 wakatime/constants.py                         |   5 -
 wakatime/heartbeat.py                         | 178 ++++
 wakatime/main.py                              | 372 +-------
 wakatime/offlinequeue.py                      | 116 ++-
 wakatime/project.py                           |  32 +-
 wakatime/session_cache.py                     |  14 +-
 wakatime/utils.py                             |   7 +-
 22 files changed, 1696 insertions(+), 2209 deletions(-)
 rename tests/samples/output/{main_test_good_config_file => configs_test_good_config_file} (75%)
 create mode 100644 wakatime/api.py
 create mode 100644 wakatime/heartbeat.py

diff --git a/tests/samples/output/main_test_good_config_file b/tests/samples/output/configs_test_good_config_file
similarity index 75%
rename from tests/samples/output/main_test_good_config_file
rename to tests/samples/output/configs_test_good_config_file
index 1a0a7d0..a58d2d8 100644
--- a/tests/samples/output/main_test_good_config_file
+++ b/tests/samples/output/configs_test_good_config_file
@@ -1,5 +1,5 @@
 Traceback (most recent call last):
-  File "{file}", line {lineno}, in parseArguments
+  File "{file}", line {lineno}, in parse_arguments
     args.timeout = int(configs.get('settings', 'timeout'))
 ValueError: invalid literal for int() with base 10: 'abc'
 
diff --git a/tests/test_arguments.py b/tests/test_arguments.py
index 757bc0a..32b2590 100644
--- a/tests/test_arguments.py
+++ b/tests/test_arguments.py
@@ -11,14 +11,13 @@ import shutil
 import sys
 import uuid
 from testfixtures import log_capture
-from wakatime.arguments import parseArguments
+from wakatime.arguments import parse_arguments
 from wakatime.compat import u
 from wakatime.constants import (
-    API_ERROR,
     AUTH_ERROR,
     SUCCESS,
-    MALFORMED_HEARTBEAT_ERROR,
 )
+from wakatime.utils import get_user_agent
 from wakatime.packages.requests.models import Response
 from . import utils
 
@@ -27,9 +26,9 @@ try:
 except (ImportError, SyntaxError):
     import json
 try:
-    from mock import ANY, call
+    from mock import ANY
 except ImportError:
-    from unittest.mock import ANY, call
+    from unittest.mock import ANY
 
 
 class ArgumentsTestCase(utils.TestCase):
@@ -83,44 +82,57 @@ class ArgumentsTestCase(utils.TestCase):
             self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
             self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
 
-    def test_argument_parsing_strips_quotes(self):
+    @log_capture()
+    def test_argument_parsing_strips_quotes(self, logs):
+        logging.disable(logging.NOTSET)
+
         response = Response()
-        response.status_code = 500
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         now = u(int(time.time()))
         config = 'tests/samples/configs/good_config.cfg'
         entity = 'tests/samples/codefiles/python.py'
-        plugin = '"abcplugin\\"withquotes"'
+        plugin = '"abc plugin\\"with quotes"'
         args = ['--file', '"' + entity + '"', '--config', config, '--time', now, '--plugin', plugin]
 
         retval = execute(args)
-        self.assertEquals(retval, API_ERROR)
+        self.assertEquals(retval, SUCCESS)
+        self.assertNothingPrinted()
+        self.assertNothingLogged(logs)
 
-        expected = 'abcplugin"withquotes'
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][2], expected)
+        ua = get_user_agent().replace('Unknown/0', 'abc plugin"with quotes')
+        heartbeat = {
+            'entity': os.path.realpath(entity),
+            'project': os.path.basename(os.path.abspath('.')),
+            'branch': ANY,
+            'time': float(now),
+            'type': 'file',
+            'cursorpos': None,
+            'dependencies': ['sqlalchemy', 'jinja', 'simplejson', 'flask', 'app', 'django', 'pygments', 'unittest', 'mock'],
+            'language': u('Python'),
+            'lineno': None,
+            'lines': 37,
+            'is_write': False,
+            'user_agent': ua,
+        }
+        self.assertHeartbeatSent(heartbeat)
+
+        self.assertHeartbeatNotSavedOffline()
+        self.assertOfflineHeartbeatsSynced()
+        self.assertSessionCacheSaved()
+
+    @log_capture()
+    def test_lineno_and_cursorpos(self, logs):
+        logging.disable(logging.NOTSET)
 
-    def test_lineno_and_cursorpos(self):
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         entity = 'tests/samples/codefiles/twolinefile.txt'
         config = 'tests/samples/configs/good_config.cfg'
         now = u(int(time.time()))
-
-        args = ['--entity', entity, '--config', config, '--time', now, '--lineno', '3', '--cursorpos', '4', '--verbose']
-        retval = execute(args)
-
-        self.assertEquals(sys.stdout.getvalue(), '')
-        self.assertEquals(sys.stderr.getvalue(), '')
-
-        self.assertEquals(retval, API_ERROR)
-
-        self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-        self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-        self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
         heartbeat = {
             'language': 'Text only',
             'lines': 2,
@@ -128,23 +140,27 @@ class ArgumentsTestCase(utils.TestCase):
             'project': os.path.basename(os.path.abspath('.')),
             'cursorpos': '4',
             'lineno': '3',
-            'branch': 'master',
+            'branch': ANY,
             'time': float(now),
+            'is_write': False,
             'type': 'file',
-        }
-        stats = {
-            u('cursorpos'): '4',
-            u('dependencies'): [],
-            u('language'): u('Text only'),
-            u('lineno'): '3',
-            u('lines'): 2,
+            'dependencies': [],
+            'user_agent': ANY,
         }
 
-        self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-        for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-            self.assertEquals(heartbeat[key], val)
-        self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-        self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+        args = ['--entity', entity, '--config', config, '--time', now, '--lineno', '3', '--cursorpos', '4', '--verbose']
+        retval = execute(args)
+
+        self.assertEquals(retval, SUCCESS)
+        self.assertNothingPrinted()
+        actual = self.getLogOutput(logs)
+        self.assertIn('WakaTime DEBUG Sending heartbeats to api', actual)
+
+        self.assertHeartbeatSent(heartbeat)
+
+        self.assertHeartbeatNotSavedOffline()
+        self.assertOfflineHeartbeatsSynced()
+        self.assertSessionCacheSaved()
 
     def test_invalid_timeout_passed_via_command_line(self):
         response = Response()
@@ -185,19 +201,16 @@ class ArgumentsTestCase(utils.TestCase):
         args = ['--file', entity, '--config', config, '--verbose']
         retval = execute(args)
         self.assertEquals(retval, SUCCESS)
-        self.assertEquals(sys.stdout.getvalue(), '')
-        self.assertEquals(sys.stderr.getvalue(), '')
-
-        log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
+        self.assertNothingPrinted()
+        actual = self.getLogOutput(logs)
         expected = 'WakaTime DEBUG File does not exist; ignoring this heartbeat.'
-        self.assertEquals(log_output, expected)
+        self.assertIn(expected, actual)
 
-        self.patched['wakatime.session_cache.SessionCache.get'].assert_not_called()
-        self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-        self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+        self.assertHeartbeatNotSent()
 
-        self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-        self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
+        self.assertHeartbeatNotSavedOffline()
+        self.assertOfflineHeartbeatsSynced()
+        self.assertSessionCacheUntouched()
 
     @log_capture()
     def test_missing_entity_argument(self, logs):
@@ -294,7 +307,7 @@ class ArgumentsTestCase(utils.TestCase):
         logging.disable(logging.NOTSET)
 
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         with utils.TemporaryDirectory() as tempdir:
@@ -308,39 +321,28 @@ class ArgumentsTestCase(utils.TestCase):
             args = ['--file', entity, '--key', key, '--time', now, '--config', 'fake-foobar']
 
             retval = execute(args)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
-            self.assertEquals(log_output, '')
-
-            self.assertEquals(retval, API_ERROR)
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
+            self.assertNothingLogged(logs)
 
             heartbeat = {
                 'language': 'Text only',
                 'lines': 0,
                 'entity': os.path.realpath(entity),
-                'project': os.path.basename(os.path.abspath('.')),
+                'project': None,
                 'time': float(now),
                 'type': 'file',
+                'cursorpos': None,
+                'dependencies': [],
+                'lineno': None,
+                'is_write': False,
+                'user_agent': ANY,
             }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): [],
-                u('language'): u('Text only'),
-                u('lineno'): None,
-                u('lines'): 0,
-            }
+            self.assertHeartbeatSent(heartbeat)
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     def test_proxy_argument(self):
         response = Response()
@@ -395,9 +397,12 @@ class ArgumentsTestCase(utils.TestCase):
 
             self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(ANY, cert=None, proxies=ANY, stream=False, timeout=60, verify=False)
 
-    def test_write_argument(self):
+    @log_capture()
+    def test_write_argument(self, logs):
+        logging.disable(logging.NOTSET)
+
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         with utils.TemporaryDirectory() as tempdir:
@@ -406,45 +411,39 @@ class ArgumentsTestCase(utils.TestCase):
             entity = os.path.realpath(os.path.join(tempdir, 'emptyfile.txt'))
             now = u(int(time.time()))
             key = str(uuid.uuid4())
+            heartbeat = {
+                'language': 'Text only',
+                'lines': 0,
+                'entity': entity,
+                'project': None,
+                'time': float(now),
+                'type': 'file',
+                'is_write': True,
+                'dependencies': [],
+                'user_agent': ANY,
+            }
 
             args = ['--file', entity, '--key', key, '--write', '--verbose',
                     '--config', 'tests/samples/configs/good_config.cfg', '--time', now]
 
             retval = execute(args)
-            self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
+            actual = self.getLogOutput(logs)
+            self.assertIn('WakaTime DEBUG Sending heartbeats to api', actual)
 
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+            self.assertHeartbeatSent(heartbeat)
 
-            heartbeat = {
-                'language': 'Text only',
-                'lines': 0,
-                'entity': entity,
-                'project': os.path.basename(os.path.abspath('.')),
-                'time': float(now),
-                'type': 'file',
-                'is_write': True,
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): [],
-                u('language'): u('Text only'),
-                u('lineno'): None,
-                u('lines'): 0,
-            }
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+    @log_capture()
+    def test_entity_type_domain(self, logs):
+        logging.disable(logging.NOTSET)
 
-    def test_entity_type_domain(self):
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         entity = 'google.com'
@@ -454,34 +453,34 @@ class ArgumentsTestCase(utils.TestCase):
         args = ['--entity', entity, '--entity-type', 'domain', '--config', config, '--time', now]
         retval = execute(args)
 
-        self.assertEquals(retval, API_ERROR)
-        self.assertEquals(sys.stdout.getvalue(), '')
-        self.assertEquals(sys.stderr.getvalue(), '')
-
-        self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-        self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-        self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+        self.assertEquals(retval, SUCCESS)
+        self.assertNothingPrinted()
+        self.assertNothingLogged(logs)
 
         heartbeat = {
             'entity': u(entity),
             'time': float(now),
             'type': 'domain',
+            'cursorpos': None,
+            'language': None,
+            'lineno': None,
+            'lines': None,
+            'is_write': False,
+            'dependencies': [],
+            'user_agent': ANY,
         }
-        stats = {
-            u('cursorpos'): None,
-            u('dependencies'): [],
-            u('language'): None,
-            u('lineno'): None,
-            u('lines'): None,
-        }
+        self.assertHeartbeatSent(heartbeat)
 
-        self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(heartbeat, ANY, None)
-        self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-        self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+        self.assertHeartbeatNotSavedOffline()
+        self.assertOfflineHeartbeatsSynced()
+        self.assertSessionCacheSaved()
+
+    @log_capture()
+    def test_entity_type_app(self, logs):
+        logging.disable(logging.NOTSET)
 
-    def test_entity_type_app(self):
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         entity = 'Firefox'
@@ -491,48 +490,197 @@ class ArgumentsTestCase(utils.TestCase):
         args = ['--entity', entity, '--entity-type', 'app', '--config', config, '--time', now]
         retval = execute(args)
 
-        self.assertEquals(retval, API_ERROR)
-        self.assertEquals(sys.stdout.getvalue(), '')
-        self.assertEquals(sys.stderr.getvalue(), '')
-
-        self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-        self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-        self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+        self.assertEquals(retval, SUCCESS)
+        self.assertNothingPrinted()
+        self.assertNothingLogged(logs)
 
         heartbeat = {
             'entity': u(entity),
             'time': float(now),
             'type': 'app',
+            'cursorpos': None,
+            'dependencies': [],
+            'language': None,
+            'lineno': None,
+            'lines': None,
+            'is_write': False,
+            'user_agent': ANY,
         }
-        stats = {
-            u('cursorpos'): None,
-            u('dependencies'): [],
-            u('language'): None,
-            u('lineno'): None,
-            u('lines'): None,
-        }
+        self.assertHeartbeatSent(heartbeat)
 
-        self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(heartbeat, ANY, None)
-        self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-        self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+        self.assertHeartbeatNotSavedOffline()
+        self.assertOfflineHeartbeatsSynced()
+        self.assertSessionCacheSaved()
+
+    @log_capture()
+    def test_old_alternate_language_argument_still_supported(self, logs):
+        logging.disable(logging.NOTSET)
 
-    def test_old_alternate_language_argument_still_supported(self):
         response = Response()
-        response.status_code = 500
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
+        language = 'Java'
         now = u(int(time.time()))
         config = 'tests/samples/configs/good_config.cfg'
         entity = 'tests/samples/codefiles/python.py'
-        args = ['--file', entity, '--config', config, '--time', now, '--alternate-language', 'JAVA']
+        args = ['--file', entity, '--config', config, '--time', now, '--alternate-language', language.upper()]
 
         retval = execute(args)
-        self.assertEquals(retval, API_ERROR)
+        self.assertEquals(retval, SUCCESS)
+        self.assertNothingPrinted()
+        self.assertNothingLogged(logs)
 
-        language = u('Java')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        heartbeat = {
+            'entity': os.path.realpath(entity),
+            'project': os.path.basename(os.path.abspath('.')),
+            'branch': ANY,
+            'time': float(now),
+            'type': 'file',
+            'cursorpos': None,
+            'dependencies': [],
+            'language': u(language),
+            'lineno': None,
+            'lines': 37,
+            'is_write': False,
+            'user_agent': ANY,
+        }
+        self.assertHeartbeatSent(heartbeat)
+
+        self.assertHeartbeatNotSavedOffline()
+        self.assertOfflineHeartbeatsSynced()
+        self.assertSessionCacheSaved()
+
+    @log_capture()
+    def test_extra_heartbeats_alternate_project_not_used(self, logs):
+        logging.disable(logging.NOTSET)
+
+        response = Response()
+        response.status_code = 201
+        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
+
+        now1 = u(int(time.time()))
+        project1 = os.path.basename(os.path.abspath('.'))
+        project_not_used = 'xyz'
+        entity1 = os.path.abspath('tests/samples/codefiles/emptyfile.txt')
+        entity2 = os.path.abspath('tests/samples/codefiles/twolinefile.txt')
+        config = 'tests/samples/configs/good_config.cfg'
+        args = ['--time', now1, '--file', entity1, '--config', config, '--extra-heartbeats']
+
+        with utils.mock.patch('wakatime.main.sys.stdin') as mock_stdin:
+            now2 = int(time.time())
+            heartbeats = json.dumps([{
+                'timestamp': now2,
+                'entity': entity2,
+                'entity_type': 'file',
+                'alternate_project': project_not_used,
+                'is_write': True,
+            }])
+            mock_stdin.readline.return_value = heartbeats
+
+            retval = execute(args)
+
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
+            self.assertNothingLogged(logs)
+
+            heartbeat = {
+                'language': 'Text only',
+                'lines': 0,
+                'entity': entity1,
+                'project': project1,
+                'branch': ANY,
+                'time': float(now1),
+                'is_write': False,
+                'type': 'file',
+                'dependencies': [],
+                'user_agent': ANY,
+            }
+            extra_heartbeats = [{
+                'language': 'Text only',
+                'lines': 2,
+                'entity': entity2,
+                'project': project1,
+                'branch': ANY,
+                'time': float(now2),
+                'is_write': True,
+                'type': 'file',
+                'dependencies': [],
+                'user_agent': ANY,
+            }]
+            self.assertHeartbeatSent(heartbeat, extra_heartbeats=extra_heartbeats)
+
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
+
+    @log_capture()
+    def test_extra_heartbeats_using_project_from_editor(self, logs):
+        logging.disable(logging.NOTSET)
+
+        response = Response()
+        response.status_code = 201
+        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
+
+        now1 = u(int(time.time()))
+        project1 = os.path.basename(os.path.abspath('.'))
+        project2 = 'xyz'
+        entity1 = os.path.abspath('tests/samples/codefiles/emptyfile.txt')
+        entity2 = os.path.abspath('tests/samples/codefiles/twolinefile.txt')
+        config = 'tests/samples/configs/good_config.cfg'
+        args = ['--time', now1, '--file', entity1, '--config', config, '--extra-heartbeats']
+
+        with utils.mock.patch('wakatime.main.sys.stdin') as mock_stdin:
+            now2 = int(time.time())
+            heartbeats = json.dumps([{
+                'timestamp': now2,
+                'entity': entity2,
+                'entity_type': 'file',
+                'project': project2,
+                'is_write': True,
+            }])
+            mock_stdin.readline.return_value = heartbeats
+
+            retval = execute(args)
+
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
+            self.assertNothingLogged(logs)
+
+            heartbeat = {
+                'language': 'Text only',
+                'lines': 0,
+                'entity': entity1,
+                'project': project1,
+                'branch': ANY,
+                'time': float(now1),
+                'is_write': False,
+                'type': 'file',
+                'dependencies': [],
+                'user_agent': ANY,
+            }
+            extra_heartbeats = [{
+                'language': 'Text only',
+                'lines': 2,
+                'entity': entity2,
+                'project': project2,
+                'branch': ANY,
+                'time': float(now2),
+                'is_write': True,
+                'type': 'file',
+                'dependencies': [],
+                'user_agent': ANY,
+            }]
+            self.assertHeartbeatSent(heartbeat, extra_heartbeats=extra_heartbeats)
+
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
+
+    @log_capture()
+    def test_extra_heartbeats_when_project_not_detected(self, logs):
+        logging.disable(logging.NOTSET)
 
-    def test_extra_heartbeats_argument(self):
         response = Response()
         response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
@@ -540,22 +688,20 @@ class ArgumentsTestCase(utils.TestCase):
         with utils.TemporaryDirectory() as tempdir:
             entity = 'tests/samples/codefiles/twolinefile.txt'
             shutil.copy(entity, os.path.join(tempdir, 'twolinefile.txt'))
-            entity = os.path.realpath(os.path.join(tempdir, 'twolinefile.txt'))
 
+            now1 = u(int(time.time()))
             project1 = os.path.basename(os.path.abspath('.'))
-            project2 = 'xyz'
             entity1 = os.path.abspath('tests/samples/codefiles/emptyfile.txt')
-            entity2 = os.path.abspath('tests/samples/codefiles/twolinefile.txt')
+            entity2 = os.path.realpath(os.path.join(tempdir, 'twolinefile.txt'))
             config = 'tests/samples/configs/good_config.cfg'
-            args = ['--file', entity1, '--config', config, '--extra-heartbeats']
+            args = ['--time', now1, '--file', entity1, '--config', config, '--extra-heartbeats']
 
             with utils.mock.patch('wakatime.main.sys.stdin') as mock_stdin:
-                now = int(time.time())
+                now2 = int(time.time())
                 heartbeats = json.dumps([{
-                    'timestamp': now,
+                    'timestamp': now2,
                     'entity': entity2,
                     'entity_type': 'file',
-                    'project': project2,
                     'is_write': True,
                 }])
                 mock_stdin.readline.return_value = heartbeats
@@ -563,27 +709,103 @@ class ArgumentsTestCase(utils.TestCase):
                 retval = execute(args)
 
                 self.assertEquals(retval, SUCCESS)
-                self.assertEquals(sys.stdout.getvalue(), '')
-                self.assertEquals(sys.stderr.getvalue(), '')
+                self.assertNothingPrinted()
+                self.assertNothingLogged(logs)
 
-                self.patched['wakatime.session_cache.SessionCache.get'].assert_has_calls([call(), call()])
-                self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-                self.patched['wakatime.session_cache.SessionCache.save'].assert_has_calls([call(ANY), call(ANY)])
+                heartbeat = {
+                    'language': 'Text only',
+                    'lines': 0,
+                    'entity': entity1,
+                    'project': project1,
+                    'branch': ANY,
+                    'time': float(now1),
+                    'is_write': False,
+                    'type': 'file',
+                    'dependencies': [],
+                    'user_agent': ANY,
+                }
+                extra_heartbeats = [{
+                    'language': 'Text only',
+                    'lines': 2,
+                    'entity': entity2,
+                    'project': None,
+                    'time': float(now2),
+                    'is_write': True,
+                    'type': 'file',
+                    'dependencies': [],
+                    'user_agent': ANY,
+                }]
+                self.assertHeartbeatSent(heartbeat, extra_heartbeats=extra_heartbeats)
 
-                self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-                self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
+                self.assertHeartbeatNotSavedOffline()
+                self.assertOfflineHeartbeatsSynced()
+                self.assertSessionCacheSaved()
 
-                calls = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args_list
+    @log_capture()
+    def test_extra_heartbeats_when_project_not_detected_alternate_project_used(self, logs):
+        logging.disable(logging.NOTSET)
 
-                body = calls[0][0][0].body
-                data = json.loads(body)
-                self.assertEquals(data.get('entity'), entity1)
-                self.assertEquals(data.get('project'), project1)
+        response = Response()
+        response.status_code = 201
+        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
-                body = calls[1][0][0].body
-                data = json.loads(body)
-                self.assertEquals(data.get('entity'), entity2)
-                self.assertEquals(data.get('project'), project2)
+        with utils.TemporaryDirectory() as tempdir:
+            entity = 'tests/samples/codefiles/twolinefile.txt'
+            shutil.copy(entity, os.path.join(tempdir, 'twolinefile.txt'))
+
+            now1 = u(int(time.time()))
+            project1 = os.path.basename(os.path.abspath('.'))
+            project2 = 'xyz'
+            entity1 = os.path.abspath('tests/samples/codefiles/emptyfile.txt')
+            entity2 = os.path.realpath(os.path.join(tempdir, 'twolinefile.txt'))
+            config = 'tests/samples/configs/good_config.cfg'
+            args = ['--time', now1, '--file', entity1, '--config', config, '--extra-heartbeats']
+
+            with utils.mock.patch('wakatime.main.sys.stdin') as mock_stdin:
+                now2 = int(time.time())
+                heartbeats = json.dumps([{
+                    'timestamp': now2,
+                    'entity': entity2,
+                    'alternate_project': project2,
+                    'entity_type': 'file',
+                    'is_write': True,
+                }])
+                mock_stdin.readline.return_value = heartbeats
+
+                retval = execute(args)
+
+                self.assertEquals(retval, SUCCESS)
+                self.assertNothingPrinted()
+                self.assertNothingLogged(logs)
+
+                heartbeat = {
+                    'language': 'Text only',
+                    'lines': 0,
+                    'entity': entity1,
+                    'project': project1,
+                    'branch': ANY,
+                    'time': float(now1),
+                    'is_write': False,
+                    'type': 'file',
+                    'dependencies': [],
+                    'user_agent': ANY,
+                }
+                extra_heartbeats = [{
+                    'language': 'Text only',
+                    'lines': 2,
+                    'entity': entity2,
+                    'project': project2,
+                    'time': float(now2),
+                    'is_write': True,
+                    'type': 'file',
+                    'dependencies': [],
+                    'user_agent': ANY,
+                }]
+                self.assertHeartbeatSent(heartbeat, extra_heartbeats=extra_heartbeats)
+
+                self.assertHeartbeatNotSavedOffline()
+                self.assertOfflineHeartbeatsSynced()
+                self.assertSessionCacheSaved()
 
     @log_capture()
     def test_extra_heartbeats_with_malformed_json(self, logs):
@@ -607,20 +829,16 @@ class ArgumentsTestCase(utils.TestCase):
                 mock_stdin.readline.return_value = heartbeats
 
                 retval = execute(args)
+                self.assertEquals(retval, SUCCESS)
+                self.assertNothingPrinted()
+                actual = self.getLogOutput(logs)
+                self.assertIn('WakaTime WARNING Malformed extra heartbeats json', actual)
 
-                self.assertEquals(retval, MALFORMED_HEARTBEAT_ERROR)
-                self.assertEquals(sys.stdout.getvalue(), '')
-                self.assertEquals(sys.stderr.getvalue(), '')
+                self.assertHeartbeatSent()
 
-                log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
-                self.assertEquals(log_output, '')
-
-                self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-                self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-                self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
-
-                self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-                self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+                self.assertHeartbeatNotSavedOffline()
+                self.assertOfflineHeartbeatsSynced()
+                self.assertSessionCacheSaved()
 
     def test_uses_wakatime_home_env_variable(self):
         with utils.TemporaryDirectory() as tempdir:
@@ -634,11 +852,11 @@ class ArgumentsTestCase(utils.TestCase):
             args = ['--file', entity, '--key', key, '--config', config]
 
             with utils.mock.patch.object(sys, 'argv', ['wakatime'] + args):
-                args, configs = parseArguments()
+                args, configs = parse_arguments()
                 self.assertEquals(args.logfile, None)
 
                 with utils.mock.patch('os.environ.get') as mock_env:
                     mock_env.return_value = os.path.realpath(tempdir)
 
-                    args, configs = parseArguments()
+                    args, configs = parse_arguments()
                     self.assertEquals(args.logfile, logfile)
diff --git a/tests/test_configs.py b/tests/test_configs.py
index 8b56f31..f0fb8d9 100644
--- a/tests/test_configs.py
+++ b/tests/test_configs.py
@@ -15,22 +15,13 @@ import uuid
 from testfixtures import log_capture
 from wakatime.compat import u, is_py3
 from wakatime.constants import (
-    API_ERROR,
     AUTH_ERROR,
     CONFIG_FILE_PARSE_ERROR,
     SUCCESS,
 )
 from wakatime.packages.requests.models import Response
 from . import utils
-
-try:
-    from .packages import simplejson as json
-except (ImportError, SyntaxError):
-    import json
-try:
-    from mock import ANY
-except ImportError:
-    from unittest.mock import ANY
+from .utils import ANY
 
 
 class ConfigsTestCase(utils.TestCase):
@@ -72,7 +63,10 @@ class ConfigsTestCase(utils.TestCase):
         self.assertEquals(sys.stderr.getvalue(), expected_stderr)
         self.patched['wakatime.session_cache.SessionCache.get'].assert_not_called()
 
-    def test_config_file_from_env(self):
+    @log_capture()
+    def test_config_file_from_env(self, logs):
+        logging.disable(logging.NOTSET)
+
         response = Response()
         response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
@@ -91,18 +85,17 @@ class ConfigsTestCase(utils.TestCase):
                 args = ['--file', entity, '--logfile', '~/.wakatime.log']
                 retval = execute(args)
                 self.assertEquals(retval, SUCCESS)
-                expected_stdout = open('tests/samples/output/main_test_good_config_file').read()
+                expected_stdout = open('tests/samples/output/configs_test_good_config_file').read()
                 traceback_file = os.path.realpath('wakatime/arguments.py')
                 lineno = int(re.search(r' line (\d+),', sys.stdout.getvalue()).group(1))
                 self.assertEquals(sys.stdout.getvalue(), expected_stdout.format(file=traceback_file, lineno=lineno))
                 self.assertEquals(sys.stderr.getvalue(), '')
 
-                self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-                self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-                self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
+                self.assertHeartbeatSent(proxies=ANY, verify=ANY)
 
-                self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-                self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
+                self.assertHeartbeatNotSavedOffline()
+                self.assertOfflineHeartbeatsSynced()
+                self.assertSessionCacheSaved()
 
     def test_missing_config_file(self):
         config = 'foo'
@@ -139,7 +132,7 @@ class ConfigsTestCase(utils.TestCase):
             args = ['--file', entity, '--config', config, '--logfile', '~/.wakatime.log']
             retval = execute(args)
             self.assertEquals(retval, SUCCESS)
-            expected_stdout = open('tests/samples/output/main_test_good_config_file').read()
+            expected_stdout = open('tests/samples/output/configs_test_good_config_file').read()
             traceback_file = os.path.realpath('wakatime/arguments.py')
             lineno = int(re.search(r' line (\d+),', sys.stdout.getvalue()).group(1))
             self.assertEquals(sys.stdout.getvalue(), expected_stdout.format(file=traceback_file, lineno=lineno))
@@ -209,9 +202,12 @@ class ConfigsTestCase(utils.TestCase):
             self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
             self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
 
-    def test_non_hidden_filename(self):
+    @log_capture()
+    def test_non_hidden_filename(self, logs):
+        logging.disable(logging.NOTSET)
+
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         with utils.TemporaryDirectory() as tempdir:
@@ -225,39 +221,33 @@ class ConfigsTestCase(utils.TestCase):
             args = ['--file', entity, '--key', key, '--config', config, '--time', now, '--logfile', '~/.wakatime.log']
 
             retval = execute(args)
-            self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
+            self.assertNothingLogged(logs)
 
             heartbeat = {
-                'language': 'Text only',
-                'lines': 2,
                 'entity': os.path.realpath(entity),
-                'project': os.path.basename(os.path.abspath('.')),
+                'project': None,
+                'branch': None,
                 'time': float(now),
                 'type': 'file',
+                'cursorpos': None,
+                'dependencies': [],
+                'language': u('Text only'),
+                'lineno': None,
+                'lines': 2,
+                'is_write': False,
+                'user_agent': ANY,
             }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): [],
-                u('language'): u('Text only'),
-                u('lineno'): None,
-                u('lines'): 2,
-            }
+            self.assertHeartbeatSent(heartbeat)
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     def test_hide_all_filenames(self):
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         with utils.TemporaryDirectory() as tempdir:
@@ -266,38 +256,35 @@ class ConfigsTestCase(utils.TestCase):
             entity = os.path.realpath(os.path.join(tempdir, 'python.py'))
             now = u(int(time.time()))
             config = 'tests/samples/configs/paranoid.cfg'
-            key = str(uuid.uuid4())
+            key = u(uuid.uuid4())
+            project = 'abcxyz'
 
-            args = ['--file', entity, '--key', key, '--config', config, '--time', now, '--logfile', '~/.wakatime.log']
+            args = ['--file', entity, '--key', key, '--config', config, '--time', now, '--logfile', '~/.wakatime.log', '--alternate-project', project]
 
             retval = execute(args)
-            self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
 
             heartbeat = {
                 'language': 'Python',
+                'lines': None,
                 'entity': 'HIDDEN.py',
-                'project': os.path.basename(os.path.abspath('.')),
+                'project': project,
                 'time': float(now),
+                'is_write': False,
                 'type': 'file',
+                'dependencies': None,
+                'user_agent': ANY,
             }
+            self.assertHeartbeatSent(heartbeat)
 
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
-            body = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].body
-            for key, val in json.loads(body).items():
-                self.assertEquals(val, heartbeat.get(key))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     def test_hide_all_filenames_from_cli_arg(self):
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         with utils.TemporaryDirectory() as tempdir:
@@ -307,37 +294,34 @@ class ConfigsTestCase(utils.TestCase):
             now = u(int(time.time()))
             config = 'tests/samples/configs/good_config.cfg'
             key = str(uuid.uuid4())
+            project = 'abcxyz'
 
-            args = ['--file', entity, '--key', key, '--config', config, '--time', now, '--hidefilenames', '--logfile', '~/.wakatime.log']
+            args = ['--file', entity, '--key', key, '--config', config, '--time', now, '--hidefilenames', '--logfile', '~/.wakatime.log', '--alternate-project', project]
 
             retval = execute(args)
-            self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
 
             heartbeat = {
                 'language': 'Python',
+                'lines': None,
                 'entity': 'HIDDEN.py',
-                'project': os.path.basename(os.path.abspath('.')),
+                'project': project,
                 'time': float(now),
+                'is_write': False,
                 'type': 'file',
+                'dependencies': None,
+                'user_agent': ANY,
             }
+            self.assertHeartbeatSent(heartbeat)
 
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
-            body = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].body
-            for key, val in json.loads(body).items():
-                self.assertEquals(val, heartbeat.get(key))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     def test_hide_matching_filenames(self):
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         with utils.TemporaryDirectory() as tempdir:
@@ -346,42 +330,38 @@ class ConfigsTestCase(utils.TestCase):
             entity = os.path.realpath(os.path.join(tempdir, 'python.py'))
             now = u(int(time.time()))
             config = 'tests/samples/configs/hide_file_names.cfg'
+            key = '033c47c9-0441-4eb5-8b3f-b51f27b31049'
+            project = 'abcxyz'
 
-            args = ['--file', entity, '--config', config, '--time', now, '--logfile', '~/.wakatime.log']
+            args = ['--file', entity, '--key', key, '--config', config, '--time', now, '--logfile', '~/.wakatime.log', '--alternate-project', project]
 
             retval = execute(args)
-            self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
 
             heartbeat = {
                 'language': 'Python',
+                'lines': None,
                 'entity': 'HIDDEN.py',
-                'project': os.path.basename(os.path.abspath('.')),
+                'project': project,
                 'time': float(now),
+                'is_write': False,
                 'type': 'file',
+                'dependencies': None,
+                'user_agent': ANY,
             }
+            headers = {
+                'Authorization': u('Basic {0}').format(u(base64.b64encode(str.encode(key) if is_py3 else key))),
+            }
+            self.assertHeartbeatSent(heartbeat, headers=headers)
 
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
-            body = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].body
-            for key, val in json.loads(body).items():
-                self.assertEquals(val, heartbeat.get(key))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-
-            headers = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].headers
-            key = '033c47c9-0441-4eb5-8b3f-b51f27b31049'
-            expected = u('Basic {0}').format(u(base64.b64encode(str.encode(key) if is_py3 else key)))
-            self.assertEquals(headers.get('Authorization'), expected)
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     def test_does_not_hide_unmatching_filenames(self):
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         with utils.TemporaryDirectory() as tempdir:
@@ -390,48 +370,39 @@ class ConfigsTestCase(utils.TestCase):
             entity = os.path.realpath(os.path.join(tempdir, 'python.py'))
             now = u(int(time.time()))
             config = 'tests/samples/configs/hide_file_names_not_python.cfg'
-            key = str(uuid.uuid4())
+            key = u(uuid.uuid4())
             dependencies = ['sqlalchemy', 'jinja', 'simplejson', 'flask', 'app', 'django', 'pygments', 'unittest', 'mock']
+            project = 'abcxyz'
 
-            args = ['--file', entity, '--key', key, '--config', config, '--time', now, '--logfile', '~/.wakatime.log']
+            args = ['--file', entity, '--key', key, '--config', config, '--time', now, '--logfile', '~/.wakatime.log', '--alternate-project', project]
 
             retval = execute(args)
-            self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-            self.maxDiff = 10000
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
 
             heartbeat = {
                 'language': 'Python',
                 'lines': 37,
                 'entity': entity,
-                'project': os.path.basename(os.path.abspath('.')),
-                'dependencies': dependencies,
+                'project': project,
                 'time': float(now),
+                'is_write': False,
                 'type': 'file',
+                'dependencies': dependencies,
+                'user_agent': ANY,
             }
+            self.assertHeartbeatSent(heartbeat)
 
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
-            body = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].body
-            for key, val in json.loads(body).items():
-                if key == 'dependencies':
-                    self.assertEquals(sorted(val), sorted(heartbeat[key]))
-                else:
-                    self.assertEquals(val, heartbeat.get(key))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     @log_capture()
     def test_does_not_hide_filenames_from_invalid_regex(self, logs):
         logging.disable(logging.NOTSET)
 
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         with utils.TemporaryDirectory() as tempdir:
@@ -445,41 +416,34 @@ class ConfigsTestCase(utils.TestCase):
             args = ['--file', entity, '--key', key, '--config', config, '--time', now, '--logfile', '~/.wakatime.log']
 
             retval = execute(args)
-            self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
 
-            log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
+            actual = self.getLogOutput(logs)
             expected = u('WakaTime WARNING Regex error (unbalanced parenthesis) for include pattern: invalid(regex')
             if self.isPy35OrNewer:
                 expected = 'WakaTime WARNING Regex error (missing ), unterminated subpattern at position 7) for include pattern: invalid(regex'
-            self.assertEquals(expected, log_output)
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+            self.assertEquals(expected, actual)
 
             heartbeat = {
                 'language': 'Text only',
                 'lines': 0,
-                'entity': entity,
-                'project': os.path.basename(os.path.abspath('.')),
+                'entity': os.path.realpath(entity),
+                'project': None,
+                'cursorpos': None,
+                'lineno': None,
                 'time': float(now),
+                'is_write': False,
                 'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): [],
-                u('language'): u('Text only'),
-                u('lineno'): None,
-                u('lines'): 0,
+                'dependencies': [],
+                'user_agent': ANY,
             }
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            self.assertHeartbeatSent(heartbeat)
+
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     @log_capture()
     def test_exclude_file(self, logs):
@@ -498,20 +462,16 @@ class ConfigsTestCase(utils.TestCase):
             args = ['--file', entity, '--config', config, '--exclude', 'empty', '--verbose', '--logfile', '~/.wakatime.log']
             retval = execute(args)
             self.assertEquals(retval, SUCCESS)
-
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
+            self.assertNothingPrinted()
+            actual = self.getLogOutput(logs)
             expected = 'WakaTime DEBUG Skipping because matches exclude pattern: empty'
-            self.assertEquals(log_output, expected)
+            self.assertEquals(actual, expected)
 
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_not_called()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+            self.assertHeartbeatNotSent()
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheUntouched()
 
     def test_hostname_set_from_config_file(self):
         response = Response()
@@ -528,16 +488,16 @@ class ConfigsTestCase(utils.TestCase):
             args = ['--file', entity, '--config', config, '--timeout', '15', '--logfile', '~/.wakatime.log']
             retval = execute(args)
             self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
 
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
+            headers = {
+                'X-Machine-Name': hostname.encode('utf-8') if is_py3 else hostname,
+            }
+            self.assertHeartbeatSent(headers=headers, proxies=ANY, timeout=15)
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
-
-            headers = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].headers
-            self.assertEquals(headers.get('X-Machine-Name'), hostname.encode('utf-8') if is_py3 else hostname)
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     def test_no_ssl_verify_from_config_file(self):
         response = Response()
@@ -553,14 +513,10 @@ class ConfigsTestCase(utils.TestCase):
             args = ['--file', entity, '--config', config, '--timeout', '15', '--logfile', '~/.wakatime.log']
             retval = execute(args)
             self.assertEquals(retval, SUCCESS)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+            self.assertNothingPrinted()
 
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
+            self.assertHeartbeatSent(proxies=ANY, timeout=15, verify=False)
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
-
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(ANY, cert=None, proxies=ANY, stream=False, timeout=15, verify=False)
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py
index 04e86d2..a86cc43 100644
--- a/tests/test_dependencies.py
+++ b/tests/test_dependencies.py
@@ -8,24 +8,16 @@ import logging
 import os
 import time
 import shutil
-import sys
 from testfixtures import log_capture
 from wakatime.compat import u
+from wakatime.constants import SUCCESS
 from wakatime.exceptions import NotYetImplemented
 from wakatime.dependencies import DependencyParser, TokenParser
 from wakatime.packages.pygments.lexers import ClassNotFound, PythonLexer
 from wakatime.packages.requests.models import Response
 from wakatime.stats import get_lexer_by_name
 from . import utils
-
-try:
-    from .packages import simplejson as json
-except (ImportError, SyntaxError):
-    import json
-try:
-    from mock import ANY
-except ImportError:
-    from unittest.mock import ANY
+from .utils import ANY
 
 
 class DependenciesTestCase(utils.TestCase):
@@ -40,6 +32,42 @@ class DependenciesTestCase(utils.TestCase):
         ['wakatime.session_cache.SessionCache.connect', None],
     ]
 
+    def shared(self, expected_dependencies=[], expected_language=ANY, expected_lines=ANY, entity='', config='good_config.cfg', extra_args=[]):
+        response = Response()
+        response.status_code = 201
+        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
+
+        config = os.path.join('tests/samples/configs', config)
+
+        with utils.TemporaryDirectory() as tempdir:
+            shutil.copy(os.path.join('tests/samples/codefiles', entity), os.path.join(tempdir, os.path.basename(entity)))
+            entity = os.path.realpath(os.path.join(tempdir, os.path.basename(entity)))
+
+            now = u(int(time.time()))
+            args = ['--file', entity, '--config', config, '--time', now] + extra_args
+
+            retval = execute(args)
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
+
+            heartbeat = {
+                'language': expected_language,
+                'lines': expected_lines,
+                'entity': os.path.realpath(entity),
+                'project': ANY,
+                'branch': ANY,
+                'dependencies': expected_dependencies,
+                'time': float(now),
+                'type': 'file',
+                'is_write': False,
+                'user_agent': ANY,
+            }
+            self.assertHeartbeatSent(heartbeat)
+
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
+
     def test_token_parser(self):
         with self.assertRaises(NotYetImplemented):
             source_file = 'tests/samples/codefiles/c_only/non_empty.h'
@@ -79,8 +107,7 @@ class DependenciesTestCase(utils.TestCase):
         log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
         self.assertEquals(log_output, '')
 
-        self.assertEquals(sys.stdout.getvalue(), '')
-        self.assertEquals(sys.stderr.getvalue(), '')
+        self.assertNothingPrinted()
 
         expected = []
         self.assertEquals(dependencies, expected)
@@ -104,8 +131,7 @@ class DependenciesTestCase(utils.TestCase):
         expected = 'WakaTime DEBUG Parsing dependencies not supported for python.FooClass'
         self.assertEquals(log_output, expected)
 
-        self.assertEquals(sys.stdout.getvalue(), '')
-        self.assertEquals(sys.stderr.getvalue(), '')
+        self.assertNothingPrinted()
 
         expected = []
         self.assertEquals(dependencies, expected)
@@ -132,162 +158,43 @@ class DependenciesTestCase(utils.TestCase):
         expected = 'WakaTime DEBUG Parsing dependencies not supported for python.FooClass'
         self.assertEquals(log_output, expected)
 
-        self.assertEquals(sys.stdout.getvalue(), '')
-        self.assertEquals(sys.stderr.getvalue(), '')
+        self.assertNothingPrinted()
 
         expected = []
         self.assertEquals(dependencies, expected)
 
     def test_io_error_suppressed_when_parsing_dependencies(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
+        with utils.mock.patch('wakatime.dependencies.open') as mock_open:
+            mock_open.side_effect = IOError('')
 
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/python.py'
-            shutil.copy(entity, os.path.join(tempdir, 'python.py'))
-            entity = os.path.realpath(os.path.join(tempdir, 'python.py'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            with utils.mock.patch('wakatime.dependencies.open') as mock_open:
-                mock_open.side_effect = IOError('')
-                retval = execute(args)
-
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('Python'),
-                'lines': 37,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): [],
-                u('language'): u('Python'),
-                u('lineno'): None,
-                u('lines'): 37,
-            }
-            expected_dependencies = []
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('dependencies', [])
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            self.shared(
+                expected_dependencies=[],
+                expected_language='Python',
+                expected_lines=37,
+                entity='python.py',
+            )
 
     def test_classnotfound_error_raised_when_passing_none_to_pygments(self):
         with self.assertRaises(ClassNotFound):
             get_lexer_by_name(None)
 
     def test_classnotfound_error_suppressed_when_parsing_dependencies(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
+        with utils.mock.patch('wakatime.stats.guess_lexer_using_filename') as mock_guess:
+            mock_guess.return_value = (None, None)
 
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/python.py'
-            shutil.copy(entity, os.path.join(tempdir, 'python.py'))
-            entity = os.path.realpath(os.path.join(tempdir, 'python.py'))
+            with utils.mock.patch('wakatime.stats.get_filetype_from_buffer') as mock_filetype:
+                mock_filetype.return_value = 'foo'
 
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            with utils.mock.patch('wakatime.stats.guess_lexer_using_filename') as mock_guess:
-                mock_guess.return_value = (None, None)
-
-                with utils.mock.patch('wakatime.stats.get_filetype_from_buffer') as mock_filetype:
-                    mock_filetype.return_value = 'foo'
-                    retval = execute(args)
-
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': None,
-                'lines': 37,
-                'dependencies': [],
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('language'): None,
-                u('lineno'): None,
-                u('lines'): 37,
-                u('dependencies'): [],
-            }
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+                self.shared(
+                    expected_dependencies=[],
+                    expected_language=None,
+                    expected_lines=37,
+                    entity='python.py',
+                )
 
     def test_python_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/python.py'
-            shutil.copy(entity, os.path.join(tempdir, 'python.py'))
-            entity = os.path.realpath(os.path.join(tempdir, 'python.py'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('Python'),
-                'lines': 37,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'dependencies': ANY,
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('Python'),
-                u('lineno'): None,
-                u('lines'): 37,
-            }
-            expected_dependencies = [
+        self.shared(
+            expected_dependencies=[
                 'app',
                 'django',
                 'flask',
@@ -297,361 +204,84 @@ class DependenciesTestCase(utils.TestCase):
                 'simplejson',
                 'sqlalchemy',
                 'unittest',
-            ]
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            ],
+            expected_language='Python',
+            expected_lines=37,
+            entity='python.py',
+        )
 
     def test_bower_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/bower.json'
-            shutil.copy(entity, os.path.join(tempdir, 'bower.json'))
-            entity = os.path.realpath(os.path.join(tempdir, 'bower.json'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-            self.assertEquals(retval, 102)
-
-            heartbeat = {
-                'language': u('JSON'),
-                'lines': 11,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'dependencies': ANY,
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('JSON'),
-                u('lineno'): None,
-                u('lines'): 11,
-            }
-            expected_dependencies = ['animate.css', 'moment', 'moment-timezone']
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            for dep in expected_dependencies:
-                self.assertIn(dep, self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies'])
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+        self.shared(
+            expected_dependencies=[
+                'bootstrap',
+                'bootstrap-daterangepicker',
+                'moment',
+                'moment-timezone',
+                'bower',
+                'animate.css',
+            ],
+            expected_language='JSON',
+            expected_lines=11,
+            entity='bower.json',
+        )
 
     def test_grunt_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/Gruntfile'
-            shutil.copy(entity, os.path.join(tempdir, 'Gruntfile'))
-            entity = os.path.realpath(os.path.join(tempdir, 'Gruntfile'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-            self.assertEquals(retval, 102)
-
-            heartbeat = {
-                'language': None,
-                'lines': 23,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'dependencies': ANY,
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): None,
-                u('lineno'): None,
-                u('lines'): 23,
-            }
-            expected_dependencies = ['grunt']
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            for dep in expected_dependencies:
-                self.assertIn(dep, self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies'])
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+        self.shared(
+            expected_dependencies=[
+                'grunt',
+            ],
+            expected_language=None,
+            expected_lines=23,
+            entity='Gruntfile',
+        )
 
     def test_java_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/java.java'
-            shutil.copy(entity, os.path.join(tempdir, 'java.java'))
-            entity = os.path.realpath(os.path.join(tempdir, 'java.java'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('Java'),
-                'lines': 20,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'dependencies': ANY,
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('Java'),
-                u('lineno'): None,
-                u('lines'): 20,
-            }
-            expected_dependencies = [
+        self.shared(
+            expected_dependencies=[
                 'colorfulwolf.webcamapplet',
                 'foobar',
-            ]
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            ],
+            expected_language='Java',
+            expected_lines=20,
+            entity='java.java',
+        )
 
     def test_c_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/c_only/non_empty.c'
-            shutil.copy(entity, os.path.join(tempdir, 'see.c'))
-            entity = os.path.realpath(os.path.join(tempdir, 'see.c'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('C'),
-                'lines': 8,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'dependencies': ANY,
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('C'),
-                u('lineno'): None,
-                u('lines'): 8,
-            }
-            expected_dependencies = [
+        self.shared(
+            expected_dependencies=[
                 'openssl',
-            ]
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            ],
+            expected_language='C',
+            expected_lines=8,
+            entity='c_only/non_empty.c',
+        )
 
     def test_cpp_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/c_and_cpp/non_empty.cpp'
-            shutil.copy(entity, os.path.join(tempdir, 'non_empty.cpp'))
-            entity = os.path.realpath(os.path.join(tempdir, 'non_empty.cpp'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('C++'),
-                'lines': 8,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'dependencies': ANY,
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('C++'),
-                u('lineno'): None,
-                u('lines'): 8,
-            }
-            expected_dependencies = [
+        self.shared(
+            expected_dependencies=[
                 'openssl',
-            ]
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            ],
+            expected_language='C++',
+            expected_lines=8,
+            entity='c_and_cpp/non_empty.cpp',
+        )
 
     def test_csharp_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/csharp/seesharp.cs'
-            shutil.copy(entity, os.path.join(tempdir, 'seesharp.cs'))
-            entity = os.path.realpath(os.path.join(tempdir, 'seesharp.cs'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('C#'),
-                'lines': 18,
-                'entity': os.path.realpath(entity),
-                'dependencies': ANY,
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('C#'),
-                u('lineno'): None,
-                u('lines'): 18,
-            }
-            expected_dependencies = [
+        self.shared(
+            expected_dependencies=[
                 'Proper',
                 'Fart',
                 'Math',
                 'WakaTime',
-            ]
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            ],
+            expected_language='C#',
+            expected_lines=18,
+            entity='csharp/seesharp.cs',
+        )
 
     def test_php_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/php.php'
-            shutil.copy(entity, os.path.join(tempdir, 'php.php'))
-            entity = os.path.realpath(os.path.join(tempdir, 'php.php'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('PHP'),
-                'lines': ANY,
-                'entity': os.path.realpath(entity),
-                'dependencies': ANY,
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('PHP'),
-                u('lineno'): None,
-                u('lines'): ANY,
-            }
-            expected_dependencies = [
+        self.shared(
+            expected_dependencies=[
                 'Interop',
                 'FooBarOne',
                 'FooBarTwo',
@@ -662,161 +292,35 @@ class DependenciesTestCase(utils.TestCase):
                 'ArrayObject',
                 "'ServiceLocator.php'",
                 "'ServiceLocatorTwo.php'",
-            ]
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            ],
+            expected_language='PHP',
+            expected_lines=116,
+            entity='php.php',
+        )
 
     def test_php_in_html_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/html-with-php.html'
-            shutil.copy(entity, os.path.join(tempdir, 'html-with-php.html'))
-            entity = os.path.realpath(os.path.join(tempdir, 'html-with-php.html'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('HTML+PHP'),
-                'lines': ANY,
-                'dependencies': ANY,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('HTML+PHP'),
-                u('lineno'): None,
-                u('lines'): ANY,
-            }
-            expected_dependencies = [
+        self.shared(
+            expected_dependencies=[
                 '"https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"',
-            ]
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            ],
+            expected_language='HTML+PHP',
+            expected_lines=22,
+            entity='html-with-php.html',
+        )
 
     def test_html_django_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/html-django.html'
-            shutil.copy(entity, os.path.join(tempdir, 'html-django.html'))
-            entity = os.path.realpath(os.path.join(tempdir, 'html-django.html'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('HTML+Django/Jinja'),
-                'lines': ANY,
-                'dependencies': ANY,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('HTML+Django/Jinja'),
-                u('lineno'): None,
-                u('lines'): ANY,
-            }
-            expected_dependencies = [
+        self.shared(
+            expected_dependencies=[
                 '"libs/json2.js"',
-            ]
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            ],
+            expected_language='HTML+Django/Jinja',
+            expected_lines=40,
+            entity='html-django.html',
+        )
 
     def test_go_dependencies_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/go.go'
-            shutil.copy(entity, os.path.join(tempdir, 'go.go'))
-            entity = os.path.realpath(os.path.join(tempdir, 'go.go'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': u('Go'),
-                'lines': 24,
-                'entity': os.path.realpath(entity),
-                'project': u(os.path.basename(os.path.realpath('.'))),
-                'dependencies': ANY,
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): ANY,
-                u('language'): u('Go'),
-                u('lineno'): None,
-                u('lines'): 24,
-            }
-            expected_dependencies = [
+        self.shared(
+            expected_dependencies=[
                 '"compress/gzip"',
                 '"direct"',
                 '"foobar"',
@@ -827,61 +331,18 @@ class DependenciesTestCase(utils.TestCase):
                 '"oldname"',
                 '"os"',
                 '"supress"',
-            ]
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-            self.assertListsEqual(dependencies, expected_dependencies)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            ],
+            expected_language='Go',
+            expected_lines=24,
+            entity='go.go',
+        )
 
     def test_dependencies_still_detected_when_alternate_language_used(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
+        with utils.mock.patch('wakatime.stats.smart_guess_lexer') as mock_guess_lexer:
+            mock_guess_lexer.return_value = None
 
-        with utils.TemporaryDirectory() as tempdir:
-            entity = 'tests/samples/codefiles/python.py'
-            shutil.copy(entity, os.path.join(tempdir, 'python.py'))
-            entity = os.path.realpath(os.path.join(tempdir, 'python.py'))
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now, '--alternate-language', 'PYTHON']
-
-            with utils.mock.patch('wakatime.stats.smart_guess_lexer') as mock_guess_lexer:
-                mock_guess_lexer.return_value = None
-
-                retval = execute(args)
-
-                self.assertEquals(retval, 102)
-                self.assertEquals(sys.stdout.getvalue(), '')
-                self.assertEquals(sys.stderr.getvalue(), '')
-
-                self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-                self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-                self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-                heartbeat = {
-                    'language': u('Python'),
-                    'lines': 37,
-                    'entity': os.path.realpath(entity),
-                    'project': u(os.path.basename(os.path.realpath('.'))),
-                    'dependencies': ANY,
-                    'time': float(now),
-                    'type': 'file',
-                }
-                stats = {
-                    u('cursorpos'): None,
-                    u('dependencies'): ANY,
-                    u('language'): u('Python'),
-                    u('lineno'): None,
-                    u('lines'): 37,
-                }
-                expected_dependencies = [
+            self.shared(
+                expected_dependencies=[
                     'app',
                     'django',
                     'flask',
@@ -891,12 +352,9 @@ class DependenciesTestCase(utils.TestCase):
                     'simplejson',
                     'sqlalchemy',
                     'unittest',
-                ]
-
-                self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-                for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                    self.assertEquals(heartbeat[key], val)
-                dependencies = self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['dependencies']
-                self.assertListsEqual(dependencies, expected_dependencies)
-                self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-                self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+                ],
+                expected_language='Python',
+                expected_lines=37,
+                entity='python.py',
+                extra_args=['--alternate-language', 'PYTHON'],
+            )
diff --git a/tests/test_languages.py b/tests/test_languages.py
index 54f2b23..adc2715 100644
--- a/tests/test_languages.py
+++ b/tests/test_languages.py
@@ -4,11 +4,14 @@
 from wakatime.main import execute
 from wakatime.packages import requests
 
+import os
 import time
 from wakatime.compat import u
+from wakatime.constants import SUCCESS
 from wakatime.packages.requests.models import Response
 from wakatime.stats import guess_language
 from . import utils
+from .utils import ANY
 
 
 class LanguagesTestCase(utils.TestCase):
@@ -23,53 +26,56 @@ class LanguagesTestCase(utils.TestCase):
         ['wakatime.session_cache.SessionCache.connect', None],
     ]
 
-    def test_c_language_detected_for_header_with_c_files_in_folder(self):
+    def shared(self, expected_language='', entity='', extra_args=[]):
         response = Response()
-        response.status_code = 500
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
-        now = u(int(time.time()))
         config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/c_only/see.h'
-        args = ['--file', entity, '--config', config, '--time', now]
+        entity = os.path.join('tests/samples/codefiles', entity)
+
+        now = u(int(time.time()))
+        args = ['--file', entity, '--config', config, '--time', now] + extra_args
 
         retval = execute(args)
-        self.assertEquals(retval, 102)
+        self.assertEquals(retval, SUCCESS)
+        self.assertNothingPrinted()
 
-        language = u('C')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        heartbeat = {
+            'language': expected_language,
+            'lines': ANY,
+            'entity': os.path.realpath(entity),
+            'project': ANY,
+            'branch': ANY,
+            'dependencies': ANY,
+            'time': float(now),
+            'type': 'file',
+            'is_write': False,
+            'user_agent': ANY,
+        }
+        self.assertHeartbeatSent(heartbeat)
+
+        self.assertHeartbeatNotSavedOffline()
+        self.assertOfflineHeartbeatsSynced()
+        self.assertSessionCacheSaved()
+
+    def test_c_language_detected_for_header_with_c_files_in_folder(self):
+        self.shared(
+            expected_language='C',
+            entity='c_only/see.h',
+        )
 
     def test_cpp_language_detected_for_header_with_c_and_cpp_files_in_folder(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/c_and_cpp/empty.h'
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('C++')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        self.shared(
+            expected_language='C++',
+            entity='c_and_cpp/empty.h',
+        )
 
     def test_c_not_detected_for_non_header_with_c_files_in_folder(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/c_and_python/see.py'
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('Python')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        self.shared(
+            expected_language='Python',
+            entity='c_and_python/see.py',
+        )
 
     def test_guess_language(self):
         with utils.mock.patch('wakatime.stats.smart_guess_lexer') as mock_guess_lexer:
@@ -80,208 +86,94 @@ class LanguagesTestCase(utils.TestCase):
             self.assertEquals(result, (None, None))
 
     def test_guess_language_from_vim_modeline(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
+        self.shared(
+            expected_language='Python',
+            entity='python_without_extension',
+        )
 
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/python_without_extension'
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('Python')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
-
-    def test_alternate_language_takes_priority_over_detected_language(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/python.py'
-        args = ['--file', entity, '--config', config, '--time', now, '--language', 'JAVA']
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('Java')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
-
-    def test_alternate_language_is_used_when_not_guessed(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        with utils.mock.patch('wakatime.stats.smart_guess_lexer') as mock_guess_lexer:
-            mock_guess_lexer.return_value = None
-            language = u('Java')
-
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-            entity = 'tests/samples/codefiles/python.py'
-            args = ['--file', entity, '--config', config, '--time', now, '--language', language.upper()]
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-
-            self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
-
-    def test_vim_alternate_language_is_used_when_not_guessed(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
+    def test_language_arg_takes_priority_over_detected_language(self):
+        self.shared(
+            expected_language='Java',
+            entity='python.py',
+            extra_args=['--language', 'JAVA']
+        )
 
+    def test_language_arg_is_used_when_not_guessed(self):
         with utils.mock.patch('wakatime.stats.smart_guess_lexer') as mock_guess_lexer:
             mock_guess_lexer.return_value = None
 
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-            entity = 'tests/samples/codefiles/python.py'
-            args = ['--file', entity, '--config', config, '--time', now, '--language', 'java', '--plugin', 'NeoVim/703 vim-wakatime/4.0.9']
+            self.shared(
+                expected_language='Java',
+                entity='python.py',
+                extra_args=['--language', 'JAVA']
+            )
 
-            retval = execute(args)
-            self.assertEquals(retval, 102)
+    def test_vim_language_arg_is_used_when_not_guessed(self):
+        with utils.mock.patch('wakatime.stats.smart_guess_lexer') as mock_guess_lexer:
+            mock_guess_lexer.return_value = None
 
-            language = u('Java')
-            self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+            self.shared(
+                expected_language='Java',
+                entity='python.py',
+                extra_args=['--language', 'java', '--plugin', 'NeoVim/703 vim-wakatime/4.0.9']
+            )
 
     def test_alternate_language_not_used_when_invalid(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         with utils.mock.patch('wakatime.stats.smart_guess_lexer') as mock_guess_lexer:
             mock_guess_lexer.return_value = None
 
-            now = u(int(time.time()))
-            config = 'tests/samples/configs/good_config.cfg'
-            entity = 'tests/samples/codefiles/python.py'
-            args = ['--file', entity, '--config', config, '--time', now, '--language', 'foo', '--plugin', 'NeoVim/703 vim-wakatime/4.0.9']
-
-            retval = execute(args)
-            self.assertEquals(retval, 102)
-
-            language = None
-            self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+            self.shared(
+                expected_language=None,
+                entity='python.py',
+                extra_args=['--language', 'foo', '--plugin', 'NeoVim/703 vim-wakatime/4.0.9']
+            )
 
     def test_error_reading_alternate_language_json_map_file(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         with utils.mock.patch('wakatime.stats.smart_guess_lexer') as mock_guess_lexer:
             mock_guess_lexer.return_value = None
 
             with utils.mock.patch('wakatime.stats.open') as mock_open:
                 mock_open.side_effect = IOError('')
 
-                now = u(int(time.time()))
-                config = 'tests/samples/configs/good_config.cfg'
-                entity = 'tests/samples/codefiles/python.py'
-                args = ['--file', entity, '--config', config, '--time', now, '--language', 'foo', '--plugin', 'NeoVim/703 vim-wakatime/4.0.9']
-
-                retval = execute(args)
-                self.assertEquals(retval, 102)
-
-                language = None
-                self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+                self.shared(
+                    expected_language=None,
+                    entity='python.py',
+                    extra_args=['--language', 'foo', '--plugin', 'NeoVim/703 vim-wakatime/4.0.9']
+                )
 
     def test_typescript_detected_over_typoscript(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/typescript.ts'
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('TypeScript')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        self.shared(
+            expected_language='TypeScript',
+            entity='typescript.ts',
+            extra_args=['--language', 'foo', '--plugin', 'NeoVim/703 vim-wakatime/4.0.9']
+        )
 
     def test_perl_detected_over_prolog(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/perl.pl'
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('Perl')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        self.shared(
+            expected_language='Perl',
+            entity='perl.pl',
+        )
 
     def test_fsharp_detected_over_forth(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/fsharp.fs'
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('F#')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        self.shared(
+            expected_language='F#',
+            entity='fsharp.fs',
+        )
 
     def test_objectivec_detected_over_matlab_when_file_empty(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/matlab/empty.m'
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('Objective-C')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        self.shared(
+            expected_language='Objective-C',
+            entity='matlab/empty.m',
+        )
 
     def test_matlab_detected(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/matlab/matlab.m'
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('Matlab')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        self.shared(
+            expected_language='Matlab',
+            entity='matlab/matlab.m',
+        )
 
     def test_matlab_detected_over_objectivec_when_mat_file_in_folder(self):
-        response = Response()
-        response.status_code = 500
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        config = 'tests/samples/configs/good_config.cfg'
-        entity = 'tests/samples/codefiles/matlab/with_mat_files/empty.m'
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        retval = execute(args)
-        self.assertEquals(retval, 102)
-
-        language = u('Matlab')
-        self.assertEqual(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('language'), language)
+        self.shared(
+            expected_language='Matlab',
+            entity='matlab/with_mat_files/empty.m',
+        )
diff --git a/tests/test_logging.py b/tests/test_logging.py
index d9feb4b..91a48bb 100644
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -10,7 +10,6 @@ import logging
 import os
 import time
 import shutil
-import sys
 from testfixtures import log_capture
 from . import utils
 
@@ -42,8 +41,7 @@ class LoggingTestCase(utils.TestCase):
 
         retval = execute(args)
         self.assertEquals(retval, 102)
-        self.assertEquals(sys.stdout.getvalue(), '')
-        self.assertEquals(sys.stderr.getvalue(), '')
+        self.assertNothingPrinted()
 
         self.assertEquals(logging.WARNING, logging.getLogger('WakaTime').level)
         logfile = os.path.realpath(os.path.expanduser('~/.wakatime.log'))
@@ -77,8 +75,7 @@ class LoggingTestCase(utils.TestCase):
 
             retval = execute(args)
             self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+            self.assertNothingPrinted()
 
             self.assertEquals(logging.WARNING, logging.getLogger('WakaTime').level)
             self.assertEquals(logfile, logging.getLogger('WakaTime').handlers[0].baseFilename)
@@ -112,8 +109,7 @@ class LoggingTestCase(utils.TestCase):
 
                 retval = execute(args)
                 self.assertEquals(retval, 102)
-                self.assertEquals(sys.stdout.getvalue(), '')
-                self.assertEquals(sys.stderr.getvalue(), '')
+                self.assertNothingPrinted()
 
                 self.assertEquals(logging.WARNING, logging.getLogger('WakaTime').level)
                 logfile = os.path.realpath(logging.getLogger('WakaTime').handlers[0].baseFilename)
@@ -135,8 +131,7 @@ class LoggingTestCase(utils.TestCase):
 
         retval = execute(args)
         self.assertEquals(retval, 102)
-        self.assertEquals(sys.stdout.getvalue(), '')
-        self.assertEquals(sys.stderr.getvalue(), '')
+        self.assertNothingPrinted()
 
         self.assertEquals(logging.DEBUG, logging.getLogger('WakaTime').level)
         logfile = os.path.realpath(os.path.expanduser('~/.wakatime.log'))
@@ -150,7 +145,7 @@ class LoggingTestCase(utils.TestCase):
         if self.isPy35OrNewer:
             expected = u('WakaTime WARNING Regex error (unbalanced parenthesis at position 15) for exclude pattern: \\(invalid regex)')
         self.assertEquals(output[1], expected)
-        self.assertEquals(output[2], u('WakaTime DEBUG Sending heartbeat to api at https://api.wakatime.com/api/v1/heartbeats'))
+        self.assertEquals(output[2], u('WakaTime DEBUG Sending heartbeats to api at https://api.wakatime.com/api/v1/heartbeats.bulk'))
         self.assertIn('Python', output[3])
         self.assertIn('response_code', output[4])
 
@@ -172,8 +167,7 @@ class LoggingTestCase(utils.TestCase):
 
             retval = execute(args)
             self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+            self.assertNothingPrinted()
 
             log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
             self.assertIn(u('WakaTime DEBUG Traceback (most recent call last):'), log_output)
@@ -197,8 +191,7 @@ class LoggingTestCase(utils.TestCase):
 
             retval = execute(args)
             self.assertEquals(retval, 102)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+            self.assertNothingPrinted()
 
             log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
             self.assertEquals(u(''), log_output)
diff --git a/tests/test_main.py b/tests/test_main.py
index dd9de8c..69f898e 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -18,19 +18,11 @@ from wakatime.constants import (
     MAX_FILE_SIZE_SUPPORTED,
     SUCCESS,
 )
+from wakatime.packages import tzlocal
 from wakatime.packages.requests.exceptions import RequestException
 from wakatime.packages.requests.models import Response
 from . import utils
-
-try:
-    from .packages import simplejson as json
-except (ImportError, SyntaxError):
-    import json
-try:
-    from mock import ANY
-except ImportError:
-    from unittest.mock import ANY
-from wakatime.packages import tzlocal
+from .utils import ANY
 
 
 class MainTestCase(utils.TestCase):
@@ -56,44 +48,26 @@ class MainTestCase(utils.TestCase):
             entity = os.path.realpath(os.path.join(tempdir, 'twolinefile.txt'))
             now = u(int(time.time()))
             key = str(uuid.uuid4())
+            heartbeat = {
+                'language': 'Text only',
+                'entity': 'HIDDEN.txt',
+                'project': None,
+                'time': float(now),
+                'type': 'file',
+                'is_write': False,
+                'user_agent': ANY,
+            }
 
             args = ['--file', entity, '--key', key,
                     '--config', 'tests/samples/configs/paranoid.cfg', '--time', now]
 
             retval = execute(args)
             self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': 'Text only',
-                'lines': 2,
-                'entity': 'HIDDEN.txt',
-                'project': os.path.basename(os.path.abspath('.')),
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): [],
-                u('language'): u('Text only'),
-                u('lineno'): None,
-                u('lines'): 2,
-            }
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
+            self.assertNothingPrinted()
+            self.assertHeartbeatSent(heartbeat)
+            self.assertHeartbeatSavedOffline()
+            self.assertOfflineHeartbeatsNotSynced()
+            self.assertSessionCacheDeleted()
 
     def test_400_response(self):
         response = Response()
@@ -106,25 +80,26 @@ class MainTestCase(utils.TestCase):
             entity = os.path.realpath(os.path.join(tempdir, 'twolinefile.txt'))
             now = u(int(time.time()))
             key = str(uuid.uuid4())
+            heartbeat = {
+                'language': 'Text only',
+                'entity': 'HIDDEN.txt',
+                'project': None,
+                'time': float(now),
+                'type': 'file',
+                'is_write': False,
+                'user_agent': ANY,
+            }
 
             args = ['--file', entity, '--key', key,
                     '--config', 'tests/samples/configs/paranoid.cfg', '--time', now]
 
             retval = execute(args)
             self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
+            self.assertNothingPrinted()
+            self.assertHeartbeatSent(heartbeat)
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsNotSynced()
+            self.assertSessionCacheDeleted()
 
     def test_401_response(self):
         response = Response()
@@ -137,44 +112,27 @@ class MainTestCase(utils.TestCase):
             entity = os.path.realpath(os.path.join(tempdir, 'twolinefile.txt'))
             now = u(int(time.time()))
             key = str(uuid.uuid4())
+            heartbeat = {
+                'language': 'Text only',
+                'lines': None,
+                'entity': 'HIDDEN.txt',
+                'project': None,
+                'time': float(now),
+                'type': 'file',
+                'is_write': False,
+                'user_agent': ANY,
+            }
 
             args = ['--file', entity, '--key', key,
                     '--config', 'tests/samples/configs/paranoid.cfg', '--time', now]
 
             retval = execute(args)
             self.assertEquals(retval, AUTH_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': 'Text only',
-                'lines': 2,
-                'entity': 'HIDDEN.txt',
-                'project': os.path.basename(os.path.abspath('.')),
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): [],
-                u('language'): u('Text only'),
-                u('lineno'): None,
-                u('lines'): 2,
-            }
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
+            self.assertNothingPrinted()
+            self.assertHeartbeatSent(heartbeat)
+            self.assertHeartbeatSavedOffline()
+            self.assertOfflineHeartbeatsNotSynced()
+            self.assertSessionCacheDeleted()
 
     @log_capture()
     def test_500_response_without_offline_logging(self, logs):
@@ -193,35 +151,39 @@ class MainTestCase(utils.TestCase):
             entity = os.path.realpath(os.path.join(tempdir, 'twolinefile.txt'))
             now = u(int(time.time()))
             key = str(uuid.uuid4())
+            heartbeat = {
+                'language': 'Text only',
+                'lines': 2,
+                'entity': entity,
+                'project': None,
+                'time': float(now),
+                'type': 'file',
+                'is_write': False,
+                'user_agent': ANY,
+                'dependencies': [],
+            }
 
             args = ['--file', entity, '--key', key, '--disableoffline',
                     '--config', 'tests/samples/configs/good_config.cfg', '--time', now]
 
             retval = execute(args)
             self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+            self.assertNothingPrinted()
 
-            log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
+            actual = self.getLogOutput(logs)
             expected = "WakaTime ERROR {'response_code': 500, 'response_content': u'fake content'}"
-            if log_output[-2] == '0':
+            if actual[-2] == '0':
                 expected = "WakaTime ERROR {'response_content': u'fake content', 'response_code': 500}"
             if is_py3:
                 expected = "WakaTime ERROR {'response_code': 500, 'response_content': 'fake content'}"
-                if log_output[-2] == '0':
+                if actual[-2] == '0':
                     expected = "WakaTime ERROR {'response_content': 'fake content', 'response_code': 500}"
-            self.assertEquals(expected, log_output)
+            self.assertEquals(expected, actual)
 
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
+            self.assertHeartbeatSent(heartbeat)
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsNotSynced()
+            self.assertSessionCacheDeleted()
 
     @log_capture()
     def test_requests_exception(self, logs):
@@ -235,54 +197,39 @@ class MainTestCase(utils.TestCase):
             entity = os.path.realpath(os.path.join(tempdir, 'twolinefile.txt'))
             now = u(int(time.time()))
             key = str(uuid.uuid4())
+            heartbeat = {
+                'language': 'Text only',
+                'lines': 2,
+                'entity': entity,
+                'project': None,
+                'time': float(now),
+                'type': 'file',
+                'is_write': False,
+                'user_agent': ANY,
+                'dependencies': [],
+            }
 
             args = ['--file', entity, '--key', key, '--verbose',
                     '--config', 'tests/samples/configs/good_config.cfg', '--time', now]
 
             retval = execute(args)
             self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+            self.assertNothingPrinted()
 
-            log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
+            actual = self.getLogOutput(logs)
             expected = 'Parsing dependencies not supported for special.TextParser'
-            self.assertIn(expected, log_output)
-            expected = 'WakaTime DEBUG Sending heartbeat to api at https://api.wakatime.com/api/v1/heartbeats'
-            self.assertIn(expected, log_output)
+            self.assertIn(expected, actual)
+            expected = 'WakaTime DEBUG Sending heartbeats to api at https://api.wakatime.com/api/v1/heartbeats.bulk'
+            self.assertIn(expected, actual)
             expected = "RequestException': u'requests exception'"
             if is_py3:
                 expected = "RequestException': 'requests exception'"
-            self.assertIn(expected, log_output)
+            self.assertIn(expected, actual)
 
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            heartbeat = {
-                'language': 'Text only',
-                'lines': 2,
-                'entity': entity,
-                'project': os.path.basename(os.path.abspath('.')),
-                'time': float(now),
-                'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): [],
-                u('language'): u('Text only'),
-                u('lineno'): None,
-                u('lines'): 2,
-            }
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
+            self.assertHeartbeatSent(heartbeat)
+            self.assertHeartbeatSavedOffline()
+            self.assertOfflineHeartbeatsNotSynced()
+            self.assertSessionCacheDeleted()
 
     @log_capture()
     def test_requests_exception_without_offline_logging(self, logs):
@@ -302,8 +249,7 @@ class MainTestCase(utils.TestCase):
 
             retval = execute(args)
             self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+            self.assertNothingPrinted()
 
             log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
             expected = "WakaTime ERROR {'RequestException': u'requests exception'}"
@@ -311,16 +257,10 @@ class MainTestCase(utils.TestCase):
                 expected = "WakaTime ERROR {'RequestException': 'requests exception'}"
             self.assertEquals(expected, log_output)
 
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
+            self.assertHeartbeatSent()
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsNotSynced()
+            self.assertSessionCacheDeleted()
 
     @log_capture()
     def test_invalid_api_key(self, logs):
@@ -345,14 +285,10 @@ class MainTestCase(utils.TestCase):
         expected = ''
         self.assertEquals(log_output, expected)
 
-        self.patched['wakatime.session_cache.SessionCache.get'].assert_not_called()
-        self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-        self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-        self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-        self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_not_called()
+        self.assertHeartbeatNotSent()
+        self.assertHeartbeatNotSavedOffline()
+        self.assertOfflineHeartbeatsNotSynced()
+        self.assertSessionCacheUntouched()
 
     def test_nonascii_hostname(self):
         response = Response()
@@ -373,18 +309,15 @@ class MainTestCase(utils.TestCase):
                 args = ['--file', entity, '--config', config]
                 retval = execute(args)
                 self.assertEquals(retval, SUCCESS)
-                self.assertEquals(sys.stdout.getvalue(), '')
-                self.assertEquals(sys.stderr.getvalue(), '')
+                self.assertNothingPrinted()
 
-                self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-                self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-                self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
-
-                self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-                self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
-
-                headers = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].headers
-                self.assertEquals(headers.get('X-Machine-Name'), hostname.encode('utf-8') if is_py3 else hostname)
+                headers = {
+                    'X-Machine-Name': hostname.encode('utf-8') if is_py3 else hostname,
+                }
+                self.assertHeartbeatSent(headers=headers)
+                self.assertHeartbeatNotSavedOffline()
+                self.assertOfflineHeartbeatsSynced()
+                self.assertSessionCacheSaved()
 
     def test_nonascii_timezone(self):
         response = Response()
@@ -406,19 +339,19 @@ class MainTestCase(utils.TestCase):
                 mock_getlocalzone.return_value = timezone
 
                 config = 'tests/samples/configs/has_everything.cfg'
-                args = ['--file', entity, '--config', config, '--timeout', '15']
+                timeout = 15
+                args = ['--file', entity, '--config', config, '--timeout', u(timeout)]
                 retval = execute(args)
                 self.assertEquals(retval, SUCCESS)
+                self.assertNothingPrinted()
 
-                self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-                self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-                self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
-
-                self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-                self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
-
-                headers = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].headers
-                self.assertEquals(headers.get('TimeZone'), u(timezone.zone).encode('utf-8') if is_py3 else timezone.zone)
+                headers = {
+                    'TimeZone': u(timezone.zone).encode('utf-8') if is_py3 else timezone.zone,
+                }
+                self.assertHeartbeatSent(headers=headers, proxies=ANY, timeout=timeout)
+                self.assertHeartbeatNotSavedOffline()
+                self.assertOfflineHeartbeatsSynced()
+                self.assertSessionCacheSaved()
 
     def test_timezone_with_invalid_encoding(self):
         response = Response()
@@ -442,21 +375,20 @@ class MainTestCase(utils.TestCase):
             with utils.mock.patch('wakatime.packages.tzlocal.get_localzone') as mock_getlocalzone:
                 mock_getlocalzone.return_value = timezone
 
+                timeout = 15
                 config = 'tests/samples/configs/has_everything.cfg'
-                args = ['--file', entity, '--config', config, '--timeout', '15']
+                args = ['--file', entity, '--config', config, '--timeout', u(timeout)]
                 retval = execute(args)
                 self.assertEquals(retval, SUCCESS)
+                self.assertNothingPrinted()
 
-                self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-                self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-                self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
-
-                self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-                self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
-
-                headers = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].headers
-                expected_tz = u(bytes('\xab', 'utf-16') if is_py3 else '\xab').encode('utf-8')
-                self.assertEquals(headers.get('TimeZone'), expected_tz)
+                headers = {
+                    'TimeZone': u(bytes('\xab', 'utf-16') if is_py3 else '\xab').encode('utf-8'),
+                }
+                self.assertHeartbeatSent(headers=headers, proxies=ANY, timeout=timeout)
+                self.assertHeartbeatNotSavedOffline()
+                self.assertOfflineHeartbeatsSynced()
+                self.assertSessionCacheSaved()
 
     def test_tzlocal_exception(self):
         response = Response()
@@ -471,20 +403,20 @@ class MainTestCase(utils.TestCase):
             with utils.mock.patch('wakatime.packages.tzlocal.get_localzone') as mock_getlocalzone:
                 mock_getlocalzone.side_effect = Exception('tzlocal exception')
 
+                timeout = 15
                 config = 'tests/samples/configs/has_everything.cfg'
-                args = ['--file', entity, '--config', config, '--timeout', '15']
+                args = ['--file', entity, '--config', config, '--timeout', u(timeout)]
                 retval = execute(args)
                 self.assertEquals(retval, SUCCESS)
+                self.assertNothingPrinted()
 
-                self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-                self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-                self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
-
-                self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-                self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
-
-                headers = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].headers
-                self.assertEquals(headers.get('TimeZone'), None)
+                headers = {
+                    'TimeZone': None,
+                }
+                self.assertHeartbeatSent(headers=headers, proxies=ANY, timeout=timeout)
+                self.assertHeartbeatNotSavedOffline()
+                self.assertOfflineHeartbeatsSynced()
+                self.assertSessionCacheSaved()
 
     def test_timezone_header(self):
         response = Response()
@@ -500,26 +432,23 @@ class MainTestCase(utils.TestCase):
             args = ['--file', entity, '--config', config]
             retval = execute(args)
             self.assertEquals(retval, SUCCESS)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
-
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_called_once_with()
+            self.assertNothingPrinted()
 
             timezone = tzlocal.get_localzone()
-            headers = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].headers
-            self.assertEquals(headers.get('TimeZone'), u(timezone.zone).encode('utf-8') if is_py3 else timezone.zone)
+            headers = {
+                'TimeZone': u(timezone.zone).encode('utf-8') if is_py3 else timezone.zone,
+            }
+            self.assertHeartbeatSent(headers=headers)
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     @log_capture()
     def test_nonascii_filename(self, logs):
         logging.disable(logging.NOTSET)
 
         response = Response()
-        response.status_code = 0
+        response.status_code = 201
         self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
         with utils.TemporaryDirectory() as tempdir:
@@ -530,54 +459,38 @@ class MainTestCase(utils.TestCase):
             now = u(int(time.time()))
             config = 'tests/samples/configs/good_config.cfg'
             key = str(uuid.uuid4())
-
-            args = ['--file', entity, '--key', key, '--config', config, '--time', now]
-
-            retval = execute(args)
-            self.assertEquals(retval, API_ERROR)
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
-
-            log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
-            self.assertEquals(log_output, '')
-
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-            self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
             heartbeat = {
                 'language': 'Text only',
                 'lines': 0,
                 'entity': os.path.realpath(entity),
-                'project': os.path.basename(os.path.abspath('.')),
+                'project': None,
                 'time': float(now),
                 'type': 'file',
-            }
-            stats = {
-                u('cursorpos'): None,
-                u('dependencies'): [],
-                u('language'): u('Text only'),
-                u('lineno'): None,
-                u('lines'): 0,
+                'is_write': False,
+                'user_agent': ANY,
+                'dependencies': [],
             }
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-            for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-                self.assertEquals(heartbeat[key], val)
-            self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+            args = ['--file', entity, '--key', key, '--config', config, '--time', now]
 
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-                ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-            )
+            retval = execute(args)
+            self.assertEquals(retval, SUCCESS)
+            self.assertNothingPrinted()
+            self.assertNothingLogged(logs)
+
+            self.assertHeartbeatSent(heartbeat)
+
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsSynced()
+            self.assertSessionCacheSaved()
 
     @log_capture()
     def test_unhandled_exception(self, logs):
         logging.disable(logging.NOTSET)
 
-        with utils.mock.patch('wakatime.main.process_heartbeat') as mock_process_heartbeat:
+        with utils.mock.patch('wakatime.main.send_heartbeats') as mock_send:
             ex_msg = 'testing unhandled exception'
-            mock_process_heartbeat.side_effect = RuntimeError(ex_msg)
+            mock_send.side_effect = RuntimeError(ex_msg)
 
             entity = 'tests/samples/codefiles/twolinefile.txt'
             config = 'tests/samples/configs/good_config.cfg'
@@ -593,11 +506,11 @@ class MainTestCase(utils.TestCase):
             log_output = u("\n").join([u(' ').join(x) for x in logs.actual()])
             self.assertIn(ex_msg, log_output)
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
-            self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-            self.patched['wakatime.session_cache.SessionCache.get'].assert_not_called()
+            self.assertHeartbeatNotSent()
 
-            self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_not_called()
+            self.assertHeartbeatNotSavedOffline()
+            self.assertOfflineHeartbeatsNotSynced()
+            self.assertSessionCacheUntouched()
 
     def test_large_file_skips_lines_count(self):
         response = Response()
@@ -607,6 +520,20 @@ class MainTestCase(utils.TestCase):
         entity = 'tests/samples/codefiles/twolinefile.txt'
         config = 'tests/samples/configs/good_config.cfg'
         now = u(int(time.time()))
+        heartbeat = {
+            'language': 'Text only',
+            'lines': None,
+            'entity': os.path.realpath(entity),
+            'project': os.path.basename(os.path.abspath('.')),
+            'cursorpos': None,
+            'lineno': None,
+            'branch': ANY,
+            'time': float(now),
+            'type': 'file',
+            'is_write': False,
+            'user_agent': ANY,
+            'dependencies': [],
+        }
 
         args = ['--entity', entity, '--config', config, '--time', now]
 
@@ -615,39 +542,10 @@ class MainTestCase(utils.TestCase):
 
             retval = execute(args)
             self.assertEquals(retval, API_ERROR)
+            self.assertNothingPrinted()
 
-            self.assertEquals(sys.stdout.getvalue(), '')
-            self.assertEquals(sys.stderr.getvalue(), '')
+        self.assertHeartbeatSent(heartbeat)
 
-        self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
-        self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
-        self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
-
-        heartbeat = {
-            'language': 'Text only',
-            'lines': None,
-            'entity': os.path.realpath(entity),
-            'project': os.path.basename(os.path.abspath('.')),
-            'cursorpos': None,
-            'lineno': None,
-            'branch': 'master',
-            'time': float(now),
-            'type': 'file',
-        }
-        stats = {
-            u('cursorpos'): None,
-            u('dependencies'): [],
-            u('language'): u('Text only'),
-            u('lineno'): None,
-            u('lines'): None,
-        }
-
-        self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
-        for key, val in self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].items():
-            self.assertEquals(heartbeat[key], val)
-        self.assertEquals(stats, json.loads(self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][1]))
-        self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
-
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
-            ANY, cert=None, proxies={}, stream=False, timeout=60, verify=True,
-        )
+        self.assertHeartbeatSavedOffline()
+        self.assertOfflineHeartbeatsNotSynced()
+        self.assertSessionCacheDeleted()
diff --git a/tests/test_offlinequeue.py b/tests/test_offlinequeue.py
index dd9d40e..df89f58 100644
--- a/tests/test_offlinequeue.py
+++ b/tests/test_offlinequeue.py
@@ -8,20 +8,13 @@ from wakatime.packages import requests
 import logging
 import os
 import sqlite3
-import sys
 import time
 from testfixtures import log_capture
 from wakatime.compat import u
-from wakatime.constants import (
-    AUTH_ERROR,
-    SUCCESS,
-)
+from wakatime.constants import SUCCESS
 from wakatime.packages.requests.models import Response
 from . import utils
-try:
-    from .packages import simplejson as json
-except (ImportError, SyntaxError):
-    import json
+from .utils import json
 
 
 class OfflineQueueTestCase(utils.TestCase):
@@ -35,7 +28,7 @@ class OfflineQueueTestCase(utils.TestCase):
 
     def test_heartbeat_saved_from_error_response(self):
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 response = Response()
@@ -49,13 +42,13 @@ class OfflineQueueTestCase(utils.TestCase):
                 args = ['--file', entity, '--config', config, '--time', now]
                 execute(args)
 
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
                 self.assertEquals(os.path.realpath(entity), saved_heartbeat['entity'])
 
     def test_heartbeat_discarded_from_400_response(self):
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 response = Response()
@@ -69,13 +62,13 @@ class OfflineQueueTestCase(utils.TestCase):
                 args = ['--file', entity, '--config', config, '--time', now]
                 execute(args)
 
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
                 self.assertEquals(None, saved_heartbeat)
 
     def test_offline_heartbeat_sent_after_success_response(self):
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 response = Response()
@@ -92,13 +85,13 @@ class OfflineQueueTestCase(utils.TestCase):
                 response.status_code = 201
                 execute(args)
 
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
                 self.assertEquals(None, saved_heartbeat)
 
     def test_all_offline_heartbeats_sent_after_success_response(self):
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 response = Response()
@@ -130,45 +123,45 @@ class OfflineQueueTestCase(utils.TestCase):
                 execute(args)
 
                 # offline queue should be empty
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
                 self.assertEquals(None, saved_heartbeat)
 
                 calls = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args_list
 
                 body = calls[0][0][0].body
-                data = json.loads(body)
+                data = json.loads(body)[0]
                 self.assertEquals(data.get('entity'), os.path.abspath(entity1))
                 self.assertEquals(data.get('project'), project1)
                 self.assertEquals(u(int(data.get('time'))), now1)
 
                 body = calls[1][0][0].body
-                data = json.loads(body)
+                data = json.loads(body)[0]
                 self.assertEquals(data.get('entity'), os.path.abspath(entity2))
                 self.assertEquals(data.get('project'), project2)
                 self.assertEquals(u(int(data.get('time'))), now2)
 
                 body = calls[2][0][0].body
-                data = json.loads(body)
+                data = json.loads(body)[0]
                 self.assertEquals(data.get('entity'), os.path.abspath(entity3))
                 self.assertEquals(data.get('project'), project3)
                 self.assertEquals(u(int(data.get('time'))), now3)
 
                 body = calls[3][0][0].body
-                data = json.loads(body)
+                data = json.loads(body)[0]
                 self.assertEquals(data.get('entity'), os.path.abspath(entity1))
                 self.assertEquals(data.get('project'), project1)
                 self.assertEquals(u(int(data.get('time'))), now1)
 
-                body = calls[4][0][0].body
-                data = json.loads(body)
+                body = calls[3][0][0].body
+                data = json.loads(body)[1]
                 self.assertEquals(data.get('entity'), os.path.abspath(entity2))
                 self.assertEquals(data.get('project'), project2)
                 self.assertEquals(u(int(data.get('time'))), now2)
 
     def test_auth_error_when_sending_offline_heartbeats(self):
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 response = Response()
@@ -214,18 +207,17 @@ class OfflineQueueTestCase(utils.TestCase):
                 self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
 
                 retval = execute(args)
-                self.assertEquals(retval, AUTH_ERROR)
+                self.assertEquals(retval, SUCCESS)
 
                 # offline queue should be empty
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
-                self.assertEquals(sys.stdout.getvalue(), '')
-                self.assertEquals(sys.stderr.getvalue(), '')
-                self.assertEquals(os.path.realpath(entity2), saved_heartbeat['entity'])
+                self.assertNothingPrinted()
+                self.assertIsNone(saved_heartbeat)
 
     def test_500_error_when_sending_offline_heartbeats(self):
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 response = Response()
@@ -274,15 +266,14 @@ class OfflineQueueTestCase(utils.TestCase):
                 self.assertEquals(retval, SUCCESS)
 
                 # offline queue should be empty
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
-                self.assertEquals(sys.stdout.getvalue(), '')
-                self.assertEquals(sys.stderr.getvalue(), '')
-                self.assertEquals(os.path.realpath(entity2), saved_heartbeat['entity'])
+                self.assertNothingPrinted()
+                self.assertIsNone(saved_heartbeat)
 
     def test_empty_project_can_be_saved(self):
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 response = Response()
@@ -296,15 +287,14 @@ class OfflineQueueTestCase(utils.TestCase):
                 args = ['--file', entity, '--config', config, '--time', now]
                 execute(args)
 
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
-                self.assertEquals(sys.stdout.getvalue(), '')
-                self.assertEquals(sys.stderr.getvalue(), '')
+                self.assertNothingPrinted()
                 self.assertEquals(os.path.realpath(entity), saved_heartbeat['entity'])
 
     def test_get_handles_connection_exception(self):
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 response = Response()
@@ -324,17 +314,17 @@ class OfflineQueueTestCase(utils.TestCase):
                     response.status_code = 201
                     execute(args)
 
-                    queue = Queue()
+                    queue = Queue(None, None)
                     saved_heartbeat = queue.pop()
                     self.assertEquals(None, saved_heartbeat)
 
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
                 self.assertEquals(os.path.realpath(entity), saved_heartbeat['entity'])
 
     def test_push_handles_connection_exception(self):
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 response = Response()
@@ -354,18 +344,18 @@ class OfflineQueueTestCase(utils.TestCase):
                     response.status_code = 201
                     execute(args)
 
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
                 self.assertEquals(None, saved_heartbeat)
 
     def test_uses_home_folder_by_default(self):
-        queue = Queue()
-        db_file = queue.get_db_file()
+        queue = Queue(None, None)
+        db_file = queue._get_db_file()
         expected = os.path.join(os.path.expanduser('~'), '.wakatime.db')
         self.assertEquals(db_file, expected)
 
     def test_uses_wakatime_home_env_variable(self):
-        queue = Queue()
+        queue = Queue(None, None)
 
         with utils.TemporaryDirectory() as tempdir:
             expected = os.path.realpath(os.path.join(tempdir, '.wakatime.db'))
@@ -373,7 +363,7 @@ class OfflineQueueTestCase(utils.TestCase):
             with utils.mock.patch('os.environ.get') as mock_env:
                 mock_env.return_value = os.path.realpath(tempdir)
 
-                actual = queue.get_db_file()
+                actual = queue._get_db_file()
                 self.assertEquals(actual, expected)
 
     @log_capture()
@@ -381,7 +371,7 @@ class OfflineQueueTestCase(utils.TestCase):
         logging.disable(logging.NOTSET)
 
         with utils.NamedTemporaryFile() as fh:
-            with utils.mock.patch('wakatime.offlinequeue.Queue.get_db_file') as mock_db_file:
+            with utils.mock.patch('wakatime.offlinequeue.Queue._get_db_file') as mock_db_file:
                 mock_db_file.return_value = fh.name
 
                 exception_msg = u("Oops, requests raised an exception. This is a test.")
@@ -394,12 +384,11 @@ class OfflineQueueTestCase(utils.TestCase):
                 args = ['--file', entity, '--config', config, '--time', now]
                 execute(args)
 
-                queue = Queue()
+                queue = Queue(None, None)
                 saved_heartbeat = queue.pop()
                 self.assertEquals(os.path.realpath(entity), saved_heartbeat['entity'])
 
-                self.assertEquals(sys.stdout.getvalue(), '')
-                self.assertEquals(sys.stderr.getvalue(), '')
+                self.assertNothingPrinted()
 
                 output = [u(' ').join(x) for x in logs.actual()]
                 self.assertIn(exception_msg, output[0])
diff --git a/tests/test_project.py b/tests/test_project.py
index b5d1b92..2af195e 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -14,10 +14,11 @@ import tempfile
 import time
 from testfixtures import log_capture
 from wakatime.compat import u
-from wakatime.constants import API_ERROR
+from wakatime.constants import API_ERROR, SUCCESS
 from wakatime.exceptions import NotYetImplemented
 from wakatime.projects.base import BaseProject
 from . import utils
+from .utils import ANY, json
 
 
 class ProjectTestCase(utils.TestCase):
@@ -32,6 +33,40 @@ class ProjectTestCase(utils.TestCase):
         ['wakatime.session_cache.SessionCache.connect', None],
     ]
 
+    def shared(self, expected_project='', expected_branch=ANY, entity='', config='good_config.cfg', extra_args=[]):
+        response = Response()
+        response.status_code = 201
+        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
+
+        config = os.path.join('tests/samples/configs', config)
+        if not os.path.exists(entity):
+            entity = os.path.realpath(os.path.join('tests/samples', entity))
+
+        now = u(int(time.time()))
+        args = ['--file', entity, '--config', config, '--time', now] + extra_args
+
+        retval = execute(args)
+        self.assertEquals(retval, SUCCESS)
+        self.assertNothingPrinted()
+
+        heartbeat = {
+            'language': ANY,
+            'lines': ANY,
+            'entity': os.path.realpath(entity),
+            'project': expected_project,
+            'branch': expected_branch,
+            'dependencies': ANY,
+            'time': float(now),
+            'type': 'file',
+            'is_write': False,
+            'user_agent': ANY,
+        }
+        self.assertHeartbeatSent(heartbeat)
+
+        self.assertHeartbeatNotSavedOffline()
+        self.assertOfflineHeartbeatsSynced()
+        self.assertSessionCacheSaved()
+
     def test_project_base(self):
         path = 'tests/samples/codefiles/see.h'
         project = BaseProject(path)
@@ -110,98 +145,63 @@ class ProjectTestCase(utils.TestCase):
         args = ['--file', entity, '--config', config, '--time', now, '--alternate-project', 'alt-project']
         execute(args)
 
-        calls = self.patched['wakatime.offlinequeue.Queue.push'].call_args_list
-        self.assertEquals(None, calls[0][0][0].get('project'))
-        self.assertEquals('alt-project', calls[1][0][0]['project'])
+        calls = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args_list
+
+        body = calls[0][0][0].body
+        data = json.loads(body)[0]
+        self.assertEquals(None, data.get('project'))
+
+        body = calls[1][0][0].body
+        data = json.loads(body)[0]
+        self.assertEquals('alt-project', data['project'])
 
     def test_wakatime_project_file(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        entity = 'tests/samples/projects/wakatime_project_file/emptyfile.txt'
-        config = 'tests/samples/configs/good_config.cfg'
-
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        execute(args)
-
-        self.assertEquals('waka-project-file', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['project'])
+        self.shared(
+            expected_project='waka-project-file',
+            entity='projects/wakatime_project_file/emptyfile.txt',
+        )
 
     def test_git_project_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         tempdir = tempfile.mkdtemp()
         shutil.copytree('tests/samples/projects/git', os.path.join(tempdir, 'git'))
         shutil.move(os.path.join(tempdir, 'git', 'dot_git'), os.path.join(tempdir, 'git', '.git'))
 
-        now = u(int(time.time()))
-        entity = os.path.join(tempdir, 'git', 'emptyfile.txt')
-        config = 'tests/samples/configs/good_config.cfg'
-
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        execute(args)
-
-        self.assertEquals('git', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['project'])
-        self.assertEquals('master', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['branch'])
+        self.shared(
+            expected_project='git',
+            expected_branch='master',
+            entity=os.path.join(tempdir, 'git', 'emptyfile.txt'),
+        )
 
     def test_ioerror_when_reading_git_branch(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         tempdir = tempfile.mkdtemp()
         shutil.copytree('tests/samples/projects/git', os.path.join(tempdir, 'git'))
         shutil.move(os.path.join(tempdir, 'git', 'dot_git'), os.path.join(tempdir, 'git', '.git'))
 
-        now = u(int(time.time()))
         entity = os.path.join(tempdir, 'git', 'emptyfile.txt')
-        config = 'tests/samples/configs/good_config.cfg'
-
-        args = ['--file', entity, '--config', config, '--time', now]
 
         with utils.mock.patch('wakatime.projects.git.open') as mock_open:
             mock_open.side_effect = IOError('')
-            execute(args)
 
-        self.assertEquals('git', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['project'])
-        self.assertEquals('master', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('branch'))
+            self.shared(
+                expected_project='git',
+                expected_branch='master',
+                entity=entity,
+            )
 
     def test_git_detached_head_not_used_as_branch(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         tempdir = tempfile.mkdtemp()
         shutil.copytree('tests/samples/projects/git-with-detached-head', os.path.join(tempdir, 'git'))
         shutil.move(os.path.join(tempdir, 'git', 'dot_git'), os.path.join(tempdir, 'git', '.git'))
 
-        now = u(int(time.time()))
         entity = os.path.join(tempdir, 'git', 'emptyfile.txt')
-        config = 'tests/samples/configs/good_config.cfg'
 
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        execute(args)
-
-        self.assertEquals('git', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['project'])
-        self.assertNotIn('branch', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0])
+        self.shared(
+            expected_project='git',
+            expected_branch=None,
+            entity=entity,
+        )
 
     def test_svn_project_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        entity = 'tests/samples/projects/svn/afolder/emptyfile.txt'
-        config = 'tests/samples/configs/good_config.cfg'
-
-        args = ['--file', entity, '--config', config, '--time', now]
-
         with utils.mock.patch('wakatime.projects.git.Git.process') as mock_git:
             mock_git.return_value = False
 
@@ -213,25 +213,16 @@ class ProjectTestCase(utils.TestCase):
                     stderr = ''
                     mock_popen.return_value = utils.DynamicIterable((stdout, stderr), max_calls=1)
 
-                    execute(args)
-
-        expected = None if platform.system() == 'Windows' else 'svn'
-        self.assertEquals(expected, self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0].get('project'))
+                    expected = None if platform.system() == 'Windows' else 'svn'
+                    self.shared(
+                        expected_project=expected,
+                        entity='projects/svn/afolder/emptyfile.txt',
+                    )
 
     def test_svn_exception_handled(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         with utils.mock.patch('wakatime.projects.git.Git.process') as mock_git:
             mock_git.return_value = False
 
-            now = u(int(time.time()))
-            entity = 'tests/samples/projects/svn/afolder/emptyfile.txt'
-            config = 'tests/samples/configs/good_config.cfg'
-
-            args = ['--file', entity, '--config', config, '--time', now]
-
             with utils.mock.patch('wakatime.projects.subversion.Subversion._has_xcode_tools') as mock_has_xcode:
                 mock_has_xcode.return_value = True
 
@@ -241,21 +232,12 @@ class ProjectTestCase(utils.TestCase):
                     with utils.mock.patch('wakatime.projects.subversion.Popen.communicate') as mock_communicate:
                         mock_communicate.side_effect = OSError('')
 
-                        execute(args)
-
-            self.assertNotIn('project', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0])
+                        self.shared(
+                            expected_project=None,
+                            entity='projects/svn/afolder/emptyfile.txt',
+                        )
 
     def test_svn_on_mac_without_xcode_tools_installed(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
-        now = u(int(time.time()))
-        entity = 'tests/samples/projects/svn/afolder/emptyfile.txt'
-        config = 'tests/samples/configs/good_config.cfg'
-
-        args = ['--file', entity, '--config', config, '--time', now]
-
         with utils.mock.patch('wakatime.projects.git.Git.process') as mock_git:
             mock_git.return_value = False
 
@@ -267,9 +249,10 @@ class ProjectTestCase(utils.TestCase):
                     stderr = ''
                     mock_popen.return_value = utils.DynamicIterable((stdout, stderr), raise_on_calls=[OSError('')])
 
-                    execute(args)
-
-            self.assertNotIn('project', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0])
+                    self.shared(
+                        expected_project=None,
+                        entity='projects/svn/afolder/emptyfile.txt',
+                    )
 
     def test_svn_on_mac_with_xcode_tools_installed(self):
         response = Response()
@@ -352,109 +335,78 @@ class ProjectTestCase(utils.TestCase):
             self.assertEquals('default', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['branch'])
 
     def test_git_submodule_detected(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         tempdir = tempfile.mkdtemp()
         shutil.copytree('tests/samples/projects/git-with-submodule', os.path.join(tempdir, 'git'))
         shutil.move(os.path.join(tempdir, 'git', 'dot_git'), os.path.join(tempdir, 'git', '.git'))
         shutil.move(os.path.join(tempdir, 'git', 'asubmodule', 'dot_git'), os.path.join(tempdir, 'git', 'asubmodule', '.git'))
 
-        now = u(int(time.time()))
         entity = os.path.join(tempdir, 'git', 'asubmodule', 'emptyfile.txt')
-        config = 'tests/samples/configs/good_config.cfg'
 
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        execute(args)
-
-        self.assertEquals('asubmodule', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['project'])
-        self.assertNotIn('asubbranch', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0])
+        self.shared(
+            expected_project='asubmodule',
+            expected_branch='asubbranch',
+            entity=entity,
+        )
 
     def test_git_submodule_detected_and_enabled_globally(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         tempdir = tempfile.mkdtemp()
         shutil.copytree('tests/samples/projects/git-with-submodule', os.path.join(tempdir, 'git'))
         shutil.move(os.path.join(tempdir, 'git', 'dot_git'), os.path.join(tempdir, 'git', '.git'))
         shutil.move(os.path.join(tempdir, 'git', 'asubmodule', 'dot_git'), os.path.join(tempdir, 'git', 'asubmodule', '.git'))
 
-        now = u(int(time.time()))
         entity = os.path.join(tempdir, 'git', 'asubmodule', 'emptyfile.txt')
-        config = 'tests/samples/configs/git-submodules-enabled.cfg'
 
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        execute(args)
-
-        self.assertEquals('asubmodule', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['project'])
-        self.assertNotIn('asubbranch', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0])
+        self.shared(
+            expected_project='asubmodule',
+            expected_branch='asubbranch',
+            entity=entity,
+            config='git-submodules-enabled.cfg',
+        )
 
     def test_git_submodule_detected_but_disabled_globally(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         tempdir = tempfile.mkdtemp()
         shutil.copytree('tests/samples/projects/git-with-submodule', os.path.join(tempdir, 'git'))
         shutil.move(os.path.join(tempdir, 'git', 'dot_git'), os.path.join(tempdir, 'git', '.git'))
         shutil.move(os.path.join(tempdir, 'git', 'asubmodule', 'dot_git'), os.path.join(tempdir, 'git', 'asubmodule', '.git'))
 
-        now = u(int(time.time()))
         entity = os.path.join(tempdir, 'git', 'asubmodule', 'emptyfile.txt')
-        config = 'tests/samples/configs/git-submodules-disabled.cfg'
 
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        execute(args)
-
-        self.assertEquals('git', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['project'])
-        self.assertNotIn('master', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0])
+        self.shared(
+            expected_project='git',
+            expected_branch='master',
+            entity=entity,
+            config='git-submodules-disabled.cfg',
+        )
 
     def test_git_submodule_detected_but_disabled_using_regex(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         tempdir = tempfile.mkdtemp()
         shutil.copytree('tests/samples/projects/git-with-submodule', os.path.join(tempdir, 'git'))
         shutil.move(os.path.join(tempdir, 'git', 'dot_git'), os.path.join(tempdir, 'git', '.git'))
         shutil.move(os.path.join(tempdir, 'git', 'asubmodule', 'dot_git'), os.path.join(tempdir, 'git', 'asubmodule', '.git'))
 
-        now = u(int(time.time()))
         entity = os.path.join(tempdir, 'git', 'asubmodule', 'emptyfile.txt')
-        config = 'tests/samples/configs/git-submodules-disabled-using-regex.cfg'
 
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        execute(args)
-
-        self.assertEquals('git', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['project'])
-        self.assertNotIn('master', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0])
+        self.shared(
+            expected_project='git',
+            expected_branch='master',
+            entity=entity,
+            config='git-submodules-disabled-using-regex.cfg',
+        )
 
     def test_git_submodule_detected_but_enabled_using_regex(self):
-        response = Response()
-        response.status_code = 0
-        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].return_value = response
-
         tempdir = tempfile.mkdtemp()
         shutil.copytree('tests/samples/projects/git-with-submodule', os.path.join(tempdir, 'git'))
         shutil.move(os.path.join(tempdir, 'git', 'dot_git'), os.path.join(tempdir, 'git', '.git'))
         shutil.move(os.path.join(tempdir, 'git', 'asubmodule', 'dot_git'), os.path.join(tempdir, 'git', 'asubmodule', '.git'))
 
-        now = u(int(time.time()))
         entity = os.path.join(tempdir, 'git', 'asubmodule', 'emptyfile.txt')
-        config = 'tests/samples/configs/git-submodules-enabled-using-regex.cfg'
 
-        args = ['--file', entity, '--config', config, '--time', now]
-
-        execute(args)
-
-        self.assertEquals('asubmodule', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0]['project'])
-        self.assertNotIn('asubbranch', self.patched['wakatime.offlinequeue.Queue.push'].call_args[0][0])
+        self.shared(
+            expected_project='asubmodule',
+            expected_branch='asubbranch',
+            entity=entity,
+            config='git-submodules-enabled-using-regex.cfg',
+        )
 
     @log_capture()
     def test_project_map(self, logs):
diff --git a/tests/test_proxy.py b/tests/test_proxy.py
index 51d405d..1b03d8a 100644
--- a/tests/test_proxy.py
+++ b/tests/test_proxy.py
@@ -174,7 +174,7 @@ class ProxyTestCase(utils.TestCase):
             self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
             self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
+            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY)
             self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
 
             expected_calls = [
@@ -212,7 +212,7 @@ class ProxyTestCase(utils.TestCase):
             self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
             self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
 
-            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY, ANY, None)
+            self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY)
             self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
 
             expected_calls = [
diff --git a/tests/test_session_cache.py b/tests/test_session_cache.py
index ce96c78..f6a2549 100644
--- a/tests/test_session_cache.py
+++ b/tests/test_session_cache.py
@@ -33,7 +33,7 @@ class SessionCacheTestCase(utils.TestCase):
         with utils.NamedTemporaryFile() as fh:
             cache = SessionCache()
 
-            with utils.mock.patch('wakatime.session_cache.SessionCache.get_db_file') as mock_dbfile:
+            with utils.mock.patch('wakatime.session_cache.SessionCache._get_db_file') as mock_dbfile:
                 mock_dbfile.return_value = fh.name
 
                 session = cache.get()
@@ -49,7 +49,7 @@ class SessionCacheTestCase(utils.TestCase):
         with utils.NamedTemporaryFile() as fh:
             cache = SessionCache()
 
-            with utils.mock.patch('wakatime.session_cache.SessionCache.get_db_file') as mock_dbfile:
+            with utils.mock.patch('wakatime.session_cache.SessionCache._get_db_file') as mock_dbfile:
                 mock_dbfile.return_value = fh.name
 
                 with utils.mock.patch('wakatime.session_cache.SessionCache.connect') as mock_connect:
@@ -69,12 +69,12 @@ class SessionCacheTestCase(utils.TestCase):
             expected = os.path.realpath(os.path.join(os.path.expanduser('~'), '.wakatime.db'))
 
             cache = SessionCache()
-            actual = cache.get_db_file()
+            actual = cache._get_db_file()
             self.assertEquals(actual, expected)
 
             with utils.mock.patch('os.environ.get') as mock_env:
                 mock_env.return_value = os.path.realpath(tempdir)
 
                 expected = os.path.realpath(os.path.join(tempdir, '.wakatime.db'))
-                actual = cache.get_db_file()
+                actual = cache._get_db_file()
                 self.assertEquals(actual, expected)
diff --git a/tests/utils.py b/tests/utils.py
index de0e630..1b41f9e 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -10,14 +10,20 @@ from wakatime.compat import u
 
 try:
     import mock
+    from mock import ANY
 except ImportError:
     import unittest.mock as mock
+    from unittest.mock import ANY
 try:
     # Python 2.6
     import unittest2 as unittest
 except ImportError:
     # Python >= 2.7
     import unittest
+try:
+    from .packages import simplejson as json
+except (ImportError, SyntaxError):
+    import json
 
 
 class TestCase(unittest.TestCase):
@@ -27,6 +33,8 @@ class TestCase(unittest.TestCase):
         # disable logging while testing
         logging.disable(logging.CRITICAL)
 
+        self.maxDiff = 1000
+
         self.patched = {}
         if hasattr(self, 'patch_these'):
             for patch_this in self.patch_these:
@@ -49,8 +57,85 @@ class TestCase(unittest.TestCase):
     def normalize_list(self, items):
         return sorted([u(x) for x in items])
 
-    def assertListsEqual(self, first_list, second_list):
-        self.assertEquals(self.normalize_list(first_list), self.normalize_list(second_list))
+    def assertListsEqual(self, first_list, second_list, message=None):
+        if isinstance(first_list, list) and isinstance(second_list, list):
+            if message:
+                self.assertEquals(self.normalize_list(first_list), self.normalize_list(second_list), message)
+            else:
+                self.assertEquals(self.normalize_list(first_list), self.normalize_list(second_list))
+        else:
+            if message:
+                self.assertEquals(first_list, second_list, message)
+            else:
+                self.assertEquals(first_list, second_list)
+
+    def assertHeartbeatNotSent(self):
+        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_not_called()
+
+    def assertHeartbeatSent(self, heartbeat=None, extra_heartbeats=[], headers=None, cert=None, proxies={}, stream=False, timeout=60, verify=True):
+        self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].assert_called_once_with(
+            ANY, cert=cert, proxies=proxies, stream=stream, timeout=timeout, verify=verify,
+        )
+
+        body = json.loads(self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].body)
+        self.assertIsInstance(body, list)
+
+        if headers:
+            actual_headers = self.patched['wakatime.packages.requests.adapters.HTTPAdapter.send'].call_args[0][0].headers
+            for key, val in headers.items():
+                self.assertEquals(actual_headers.get(key), val, u('Expected api request to have header {0}={1}, instead {0}={2}').format(u(key), u(actual_headers.get(key)), u(val)))
+
+        if heartbeat:
+            keys = list(body[0].keys()) + list(heartbeat.keys())
+            for key in keys:
+                if isinstance(heartbeat.get(key), list):
+                    self.assertListsEqual(heartbeat.get(key), body[0].get(key), u('Expected heartbeat to be sent with {0}={1}, instead {0}={2}').format(u(key), u(heartbeat.get(key)), u(body[0].get(key))))
+                else:
+                    self.assertEquals(heartbeat.get(key), body[0].get(key), u('Expected heartbeat to be sent with {0}={1}, instead {0}={2}').format(u(key), u(heartbeat.get(key)), u(body[0].get(key))))
+
+        if extra_heartbeats:
+            for i in range(len(extra_heartbeats)):
+                keys = list(body[i + 1].keys()) + list(extra_heartbeats[i].keys())
+                for key in keys:
+                    self.assertEquals(extra_heartbeats[i].get(key), body[i + 1].get(key), u('Expected extra heartbeat {3} to be sent with {0}={1}, instead {0}={2}').format(u(key), u(extra_heartbeats[i].get(key)), u(body[i + 1].get(key)), i))
+
+    def assertSessionCacheUntouched(self):
+        self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
+        self.patched['wakatime.session_cache.SessionCache.get'].assert_not_called()
+        self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+
+    def assertSessionCacheDeleted(self):
+        self.patched['wakatime.session_cache.SessionCache.delete'].assert_called_once_with()
+        self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
+        self.patched['wakatime.session_cache.SessionCache.save'].assert_not_called()
+
+    def assertSessionCacheSaved(self):
+        self.patched['wakatime.session_cache.SessionCache.save'].assert_called_once_with(ANY)
+        self.patched['wakatime.session_cache.SessionCache.get'].assert_called_once_with()
+        self.patched['wakatime.session_cache.SessionCache.delete'].assert_not_called()
+
+    def assertHeartbeatSavedOffline(self):
+        self.patched['wakatime.offlinequeue.Queue.push'].assert_called_once_with(ANY)
+        self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+
+    def assertHeartbeatNotSavedOffline(self):
+        self.patched['wakatime.offlinequeue.Queue.push'].assert_not_called()
+
+    def assertOfflineHeartbeatsSynced(self):
+        self.patched['wakatime.offlinequeue.Queue.pop'].assert_called()
+
+    def assertOfflineHeartbeatsNotSynced(self):
+        self.patched['wakatime.offlinequeue.Queue.pop'].assert_not_called()
+
+    def assertNothingPrinted(self):
+        self.assertEquals(sys.stdout.getvalue(), '')
+        self.assertEquals(sys.stderr.getvalue(), '')
+
+    def assertNothingLogged(self, logs):
+        self.assertEquals(self.getLogOutput(logs), '')
+
+    def getLogOutput(self, logs):
+        return u("\n").join([u(' ').join(x) for x in logs.actual()])
 
     @property
     def isPy35OrNewer(self):
@@ -65,6 +150,7 @@ try:
 except ImportError:
     # Python < 3
     import shutil
+
     class TemporaryDirectory(object):
         """Context manager for tempfile.mkdtemp().
 
@@ -111,8 +197,10 @@ class DynamicIterable(object):
         self.raise_on_calls = raise_on_calls
         self.index = 0
         self.data = data
+
     def __iter__(self):
         return self
+
     def __next__(self):
         if self.raise_on_calls and self.called < len(self.raise_on_calls) and self.raise_on_calls[self.called]:
             raise self.raise_on_calls[self.called]
@@ -125,5 +213,6 @@ class DynamicIterable(object):
         if not self.max_calls or self.called <= self.max_calls:
             return val
         return None
+
     def next(self):
         return self.__next__()
diff --git a/wakatime/api.py b/wakatime/api.py
new file mode 100644
index 0000000..7696e58
--- /dev/null
+++ b/wakatime/api.py
@@ -0,0 +1,177 @@
+# -*- coding: utf-8 -*-
+"""
+    wakatime.api
+    ~~~~~~~~~~~~
+
+    :copyright: (c) 2017 Alan Hamlett.
+    :license: BSD, see LICENSE for more details.
+"""
+
+
+from __future__ import print_function
+
+import base64
+import logging
+import sys
+import traceback
+
+from .compat import u, is_py3, json
+from .constants import (
+    API_ERROR,
+    AUTH_ERROR,
+    SUCCESS,
+    UNKNOWN_ERROR,
+)
+
+from .offlinequeue import Queue
+from .packages.requests.exceptions import RequestException
+from .session_cache import SessionCache
+from .utils import get_hostname, get_user_agent
+from .packages import tzlocal
+
+
+log = logging.getLogger('WakaTime')
+
+
+try:
+    from .packages import requests
+except ImportError:
+    log.traceback(logging.ERROR)
+    print(traceback.format_exc())
+    log.error('Please upgrade Python to the latest version.')
+    print('Please upgrade Python to the latest version.')
+    sys.exit(UNKNOWN_ERROR)
+
+
+def send_heartbeats(heartbeats, args, configs, use_ntlm_proxy=False):
+    """Send heartbeats to WakaTime API.
+
+    Returns `SUCCESS` when heartbeat was sent, otherwise returns an error code.
+    """
+
+    if len(heartbeats) == 0:
+        return SUCCESS
+
+    api_url = args.api_url
+    if not api_url:
+        api_url = 'https://api.wakatime.com/api/v1/heartbeats.bulk'
+    log.debug('Sending heartbeats to api at %s' % api_url)
+    timeout = args.timeout
+    if not timeout:
+        timeout = 60
+
+    data = [h.sanitize().dict() for h in heartbeats]
+    log.debug(data)
+
+    # setup api request
+    request_body = json.dumps(data)
+    api_key = u(base64.b64encode(str.encode(args.key) if is_py3 else args.key))
+    auth = u('Basic {api_key}').format(api_key=api_key)
+    headers = {
+        'User-Agent': get_user_agent(args.plugin),
+        'Content-Type': 'application/json',
+        'Accept': 'application/json',
+        'Authorization': auth,
+    }
+
+    hostname = get_hostname(args)
+    if hostname:
+        headers['X-Machine-Name'] = u(hostname).encode('utf-8')
+
+    # add Olson timezone to request
+    try:
+        tz = tzlocal.get_localzone()
+    except:
+        tz = None
+    if tz:
+        headers['TimeZone'] = u(tz.zone).encode('utf-8')
+
+    session_cache = SessionCache()
+    session = session_cache.get()
+
+    should_try_ntlm = False
+    proxies = {}
+    if args.proxy:
+        if use_ntlm_proxy:
+            from .packages.requests_ntlm import HttpNtlmAuth
+            username = args.proxy.rsplit(':', 1)
+            password = ''
+            if len(username) == 2:
+                password = username[1]
+            username = username[0]
+            session.auth = HttpNtlmAuth(username, password, session)
+        else:
+            should_try_ntlm = '\\' in args.proxy
+            proxies['https'] = args.proxy
+
+    # send request to api
+    response, code = None, None
+    try:
+        response = session.post(api_url, data=request_body, headers=headers,
+                                proxies=proxies, timeout=timeout,
+                                verify=not args.nosslverify)
+    except RequestException:
+        if should_try_ntlm:
+            return send_heartbeats(heartbeats, args, configs, use_ntlm_proxy=True)
+        else:
+            exception_data = {
+                sys.exc_info()[0].__name__: u(sys.exc_info()[1]),
+            }
+            if log.isEnabledFor(logging.DEBUG):
+                exception_data['traceback'] = traceback.format_exc()
+            if args.offline:
+                queue = Queue(args, configs)
+                queue.push_many(heartbeats)
+                if log.isEnabledFor(logging.DEBUG):
+                    log.warn(exception_data)
+            else:
+                log.error(exception_data)
+
+    except:  # delete cached session when requests raises unknown exception
+        if should_try_ntlm:
+            return send_heartbeats(heartbeats, args, configs, use_ntlm_proxy=True)
+        else:
+            exception_data = {
+                sys.exc_info()[0].__name__: u(sys.exc_info()[1]),
+                'traceback': traceback.format_exc(),
+            }
+            if args.offline:
+                queue = Queue(args, configs)
+                queue.push_many(heartbeats)
+                log.warn(exception_data)
+
+    else:
+        code = response.status_code if response is not None else None
+        content = response.text if response is not None else None
+        if code == requests.codes.created or code == requests.codes.accepted:
+            log.debug({
+                'response_code': code,
+            })
+            session_cache.save(session)
+            return SUCCESS
+
+        if should_try_ntlm:
+            return send_heartbeats(heartbeats, args, configs, use_ntlm_proxy=True)
+        else:
+            if args.offline:
+                if code == 400:
+                    log.error({
+                        'response_code': code,
+                        'response_content': content,
+                    })
+                else:
+                    if log.isEnabledFor(logging.DEBUG):
+                        log.warn({
+                            'response_code': code,
+                            'response_content': content,
+                        })
+                    queue = Queue(args, configs)
+                    queue.push_many(heartbeats)
+            else:
+                log.error({
+                    'response_code': code,
+                    'response_content': content,
+                })
+
+    session_cache.delete()
+    return AUTH_ERROR if code == 401 else API_ERROR
diff --git a/wakatime/arguments.py b/wakatime/arguments.py
index 9b461e1..81e24fe 100644
--- a/wakatime/arguments.py
+++ b/wakatime/arguments.py
@@ -45,7 +45,7 @@ class StoreWithoutQuotes(argparse.Action):
         setattr(namespace, self.dest, values)
 
 
-def parseArguments():
+def parse_arguments():
     """Parse command line arguments and configs from ~/.wakatime.cfg.
     Command line arguments take precedence over config file settings.
     Returns instances of ArgumentParser and SafeConfigParser.
diff --git a/wakatime/compat.py b/wakatime/compat.py
index c06a6b1..047f244 100644
--- a/wakatime/compat.py
+++ b/wakatime/compat.py
@@ -9,6 +9,7 @@
     :license: BSD, see LICENSE for more details.
 """
 
+
 import codecs
 import sys
 
@@ -91,3 +92,9 @@ except ImportError:  # pragma: nocover
             name = _resolve_name(name[level:], package, level)
         __import__(name)
         return sys.modules[name]
+
+
+try:
+    from .packages import simplejson as json
+except (ImportError, SyntaxError):
+    import json
diff --git a/wakatime/constants.py b/wakatime/constants.py
index 3e1905f..0ee51be 100644
--- a/wakatime/constants.py
+++ b/wakatime/constants.py
@@ -34,11 +34,6 @@ Exit code used when there was an unhandled exception.
 """
 UNKNOWN_ERROR = 105
 
-""" Malformed Heartbeat Error
-Exit code used when the JSON input from `--extra-heartbeats` is malformed.
-"""
-MALFORMED_HEARTBEAT_ERROR = 106
-
 """ Connection Error
 Exit code used when there was proxy or other problem connecting to the WakaTime
 API servers.
diff --git a/wakatime/heartbeat.py b/wakatime/heartbeat.py
new file mode 100644
index 0000000..31c5044
--- /dev/null
+++ b/wakatime/heartbeat.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+"""
+    wakatime.heartbeat
+    ~~~~~~~~~~~~~~~~~~
+    :copyright: (c) 2017 Alan Hamlett.
+    :license: BSD, see LICENSE for more details.
+"""
+
+
+import os
+import logging
+import re
+
+from .compat import u, json
+from .project import get_project_info
+from .stats import get_file_stats
+from .utils import get_user_agent, should_exclude, format_file_path
+
+
+log = logging.getLogger('WakaTime')
+
+
+class Heartbeat(object):
+    """Heartbeat data for sending to API or storing in offline cache."""
+
+    skip = False
+    args = None
+    configs = None
+
+    time = None
+    entity = None
+    type = None
+    is_write = None
+    project = None
+    branch = None
+    language = None
+    dependencies = None
+    lines = None
+    lineno = None
+    cursorpos = None
+    user_agent = None
+
+    def __init__(self, data, args, configs, _clone=None):
+        self.args = args
+        self.configs = configs
+
+        self.entity = data.get('entity')
+        self.time = data.get('time', data.get('timestamp'))
+        self.is_write = data.get('is_write')
+        self.user_agent = data.get('user_agent') or get_user_agent(args.plugin)
+
+        self.type = data.get('type', data.get('entity_type'))
+        if self.type not in ['file', 'domain', 'app']:
+            self.type = 'file'
+
+        if not _clone:
+            exclude = self._excluded_by_pattern()
+            if exclude:
+                self.skip = u('Skipping because matches exclude pattern: {pattern}').format(
+                    pattern=u(exclude),
+                )
+                return
+            if self.type == 'file':
+                self.entity = format_file_path(self.entity)
+            if self.type == 'file' and not os.path.isfile(self.entity):
+                self.skip = u('File does not exist; ignoring this heartbeat.')
+                return
+
+            project, branch = get_project_info(configs, self, data)
+            self.project = project
+            self.branch = branch
+
+            stats = get_file_stats(self.entity,
+                                   entity_type=self.type,
+                                   lineno=data.get('lineno'),
+                                   cursorpos=data.get('cursorpos'),
+                                   plugin=args.plugin,
+                                   language=data.get('language'))
+        else:
+            self.project = data.get('project')
+            self.branch = data.get('branch')
+            stats = data
+
+        for key in ['language', 'dependencies', 'lines', 'lineno', 'cursorpos']:
+            if stats.get(key) is not None:
+                setattr(self, key, stats[key])
+
+    def update(self, attrs):
+        """Return a copy of the current Heartbeat with updated attributes."""
+
+        data = self.dict()
+        data.update(attrs)
+        heartbeat = Heartbeat(data, self.args, self.configs, _clone=True)
+        heartbeat.skip = self.skip
+        return heartbeat
+
+    def sanitize(self):
+        """Removes sensitive data including file names and dependencies.
+
+        Returns a Heartbeat.
+        """
+
+        if not self.args.hidefilenames:
+            return self
+
+        if self.entity is None:
+            return self
+
+        if self.type != 'file':
+            return self
+
+        for pattern in self.args.hidefilenames:
+            try:
+                compiled = re.compile(pattern, re.IGNORECASE)
+                if compiled.search(self.entity):
+
+                    sanitized = {}
+                    sensitive = ['dependencies', 'lines', 'lineno', 'cursorpos', 'branch']
+                    for key, val in self.items():
+                        if key in sensitive:
+                            sanitized[key] = None
+                        else:
+                            sanitized[key] = val
+
+                    extension = u(os.path.splitext(self.entity)[1])
+                    sanitized['entity'] = u('HIDDEN{0}').format(extension)
+
+                    return self.update(sanitized)
+
+            except re.error as ex:
+                log.warning(u('Regex error ({msg}) for include pattern: {pattern}').format(
+                    msg=u(ex),
+                    pattern=u(pattern),
+                ))
+
+        return self
+
+    def json(self):
+        return json.dumps(self.dict())
+
+    def dict(self):
+        return {
+            'time': self.time,
+            'entity': self.entity,
+            'type': self.type,
+            'is_write': self.is_write,
+            'project': self.project,
+            'branch': self.branch,
+            'language': self.language,
+            'dependencies': self.dependencies,
+            'lines': self.lines,
+            'lineno': self.lineno,
+            'cursorpos': self.cursorpos,
+            'user_agent': self.user_agent,
+        }
+
+    def items(self):
+        return self.dict().items()
+
+    def get_id(self):
+        return u('{h.time}-{h.type}-{h.project}-{h.branch}-{h.entity}-{h.is_write}').format(
+            h=self,
+        )
+
+    def _excluded_by_pattern(self):
+        return should_exclude(self.entity, self.args.include, self.args.exclude)
+
+    def __repr__(self):
+        return self.json()
+
+    def __bool__(self):
+        return not self.skip
+
+    def __nonzero__(self):
+        return self.__bool__()
+
+    def __getitem__(self, key):
+        return self.dict()[key]
diff --git a/wakatime/main.py b/wakatime/main.py
index 42df6fe..351f606 100644
--- a/wakatime/main.py
+++ b/wakatime/main.py
@@ -11,387 +11,67 @@
 
 from __future__ import print_function
 
-import base64
 import logging
 import os
-import re
 import sys
 import traceback
-import socket
 
 pwd = os.path.dirname(os.path.abspath(__file__))
 sys.path.insert(0, os.path.dirname(pwd))
 sys.path.insert(0, os.path.join(pwd, 'packages'))
 
 from .__about__ import __version__
-from .arguments import parseArguments
-from .compat import u, is_py3
+from .api import send_heartbeats
+from .arguments import parse_arguments
+from .compat import u, json
 from .constants import (
-    API_ERROR,
-    AUTH_ERROR,
     SUCCESS,
     UNKNOWN_ERROR,
-    MALFORMED_HEARTBEAT_ERROR,
 )
 from .logger import setup_logging
 
 log = logging.getLogger('WakaTime')
 
-try:
-    from .packages import requests
-except ImportError:
-    log.traceback(logging.ERROR)
-    print(traceback.format_exc())
-    log.error('Please upgrade Python to the latest version.')
-    print('Please upgrade Python to the latest version.')
-    sys.exit(UNKNOWN_ERROR)
-
+from .heartbeat import Heartbeat
 from .offlinequeue import Queue
-from .packages.requests.exceptions import RequestException
-from .project import get_project_info
-from .session_cache import SessionCache
-from .stats import get_file_stats
-from .utils import get_user_agent, should_exclude, format_file_path
-try:
-    from .packages import simplejson as json  # pragma: nocover
-except (ImportError, SyntaxError):  # pragma: nocover
-    import json
-from .packages import tzlocal
-
-
-def send_heartbeat(project=None, branch=None, hostname=None, stats={}, key=None,
-                   entity=None, timestamp=None, is_write=None, plugin=None,
-                   offline=None, entity_type='file', hidefilenames=None,
-                   proxy=None, nosslverify=None, api_url=None, timeout=None,
-                   use_ntlm_proxy=False, **kwargs):
-    """Sends heartbeat as POST request to WakaTime api server.
-
-    Returns `SUCCESS` when heartbeat was sent, otherwise returns an
-    error code constant.
-    """
-
-    if not api_url:
-        api_url = 'https://api.wakatime.com/api/v1/heartbeats'
-    if not timeout:
-        timeout = 60
-    log.debug('Sending heartbeat to api at %s' % api_url)
-    data = {
-        'time': timestamp,
-        'entity': entity,
-        'type': entity_type,
-    }
-    if stats.get('lines'):
-        data['lines'] = stats['lines']
-    if stats.get('language'):
-        data['language'] = stats['language']
-    if stats.get('dependencies'):
-        data['dependencies'] = stats['dependencies']
-    if stats.get('lineno'):
-        data['lineno'] = stats['lineno']
-    if stats.get('cursorpos'):
-        data['cursorpos'] = stats['cursorpos']
-    if is_write:
-        data['is_write'] = is_write
-    if project:
-        data['project'] = project
-    if branch:
-        data['branch'] = branch
-
-    if hidefilenames and entity is not None and entity_type == 'file':
-        for pattern in hidefilenames:
-            try:
-                compiled = re.compile(pattern, re.IGNORECASE)
-                if compiled.search(entity):
-                    extension = u(os.path.splitext(data['entity'])[1])
-                    data['entity'] = u('HIDDEN{0}').format(extension)
-
-                    # also delete any sensitive info when hiding file names
-                    sensitive = ['dependencies', 'lines', 'lineno', 'cursorpos', 'branch']
-                    for sensitiveKey in sensitive:
-                        if sensitiveKey in data:
-                            del data[sensitiveKey]
-
-                    break
-            except re.error as ex:
-                log.warning(u('Regex error ({msg}) for include pattern: {pattern}').format(
-                    msg=u(ex),
-                    pattern=u(pattern),
-                ))
-
-    log.debug(data)
-
-    # setup api request
-    request_body = json.dumps(data)
-    api_key = u(base64.b64encode(str.encode(key) if is_py3 else key))
-    auth = u('Basic {api_key}').format(api_key=api_key)
-    headers = {
-        'User-Agent': get_user_agent(plugin),
-        'Content-Type': 'application/json',
-        'Accept': 'application/json',
-        'Authorization': auth,
-    }
-    if hostname:
-        headers['X-Machine-Name'] = u(hostname).encode('utf-8')
-
-    # add Olson timezone to request
-    try:
-        tz = tzlocal.get_localzone()
-    except:
-        tz = None
-    if tz:
-        headers['TimeZone'] = u(tz.zone).encode('utf-8')
-
-    session_cache = SessionCache()
-    session = session_cache.get()
-
-    should_try_ntlm = False
-    proxies = {}
-    if proxy:
-        if use_ntlm_proxy:
-            from .packages.requests_ntlm import HttpNtlmAuth
-            username = proxy.rsplit(':', 1)
-            password = ''
-            if len(username) == 2:
-                password = username[1]
-            username = username[0]
-            session.auth = HttpNtlmAuth(username, password, session)
-        else:
-            should_try_ntlm = '\\' in proxy
-            proxies['https'] = proxy
-
-    # send request to api
-    response = None
-    try:
-        response = session.post(api_url, data=request_body, headers=headers,
-                                proxies=proxies, timeout=timeout,
-                                verify=not nosslverify)
-    except RequestException:
-        if should_try_ntlm:
-            return send_heartbeat(
-                project=project,
-                entity=entity,
-                timestamp=timestamp,
-                branch=branch,
-                hostname=hostname,
-                stats=stats,
-                key=key,
-                is_write=is_write,
-                plugin=plugin,
-                offline=offline,
-                hidefilenames=hidefilenames,
-                entity_type=entity_type,
-                proxy=proxy,
-                api_url=api_url,
-                timeout=timeout,
-                use_ntlm_proxy=True,
-            )
-        else:
-            exception_data = {
-                sys.exc_info()[0].__name__: u(sys.exc_info()[1]),
-            }
-            if log.isEnabledFor(logging.DEBUG):
-                exception_data['traceback'] = traceback.format_exc()
-            if offline:
-                queue = Queue()
-                queue.push(data, json.dumps(stats), plugin)
-                if log.isEnabledFor(logging.DEBUG):
-                    log.warn(exception_data)
-            else:
-                log.error(exception_data)
-
-    except:  # delete cached session when requests raises unknown exception
-        if should_try_ntlm:
-            return send_heartbeat(
-                project=project,
-                entity=entity,
-                timestamp=timestamp,
-                branch=branch,
-                hostname=hostname,
-                stats=stats,
-                key=key,
-                is_write=is_write,
-                plugin=plugin,
-                offline=offline,
-                hidefilenames=hidefilenames,
-                entity_type=entity_type,
-                proxy=proxy,
-                api_url=api_url,
-                timeout=timeout,
-                use_ntlm_proxy=True,
-            )
-        else:
-            exception_data = {
-                sys.exc_info()[0].__name__: u(sys.exc_info()[1]),
-                'traceback': traceback.format_exc(),
-            }
-            if offline:
-                queue = Queue()
-                queue.push(data, json.dumps(stats), plugin)
-                log.warn(exception_data)
-
-    else:
-        code = response.status_code if response is not None else None
-        content = response.text if response is not None else None
-        if code == requests.codes.created or code == requests.codes.accepted:
-            log.debug({
-                'response_code': code,
-            })
-            session_cache.save(session)
-            return SUCCESS
-        if should_try_ntlm:
-            return send_heartbeat(
-                project=project,
-                entity=entity,
-                timestamp=timestamp,
-                branch=branch,
-                hostname=hostname,
-                stats=stats,
-                key=key,
-                is_write=is_write,
-                plugin=plugin,
-                offline=offline,
-                hidefilenames=hidefilenames,
-                entity_type=entity_type,
-                proxy=proxy,
-                api_url=api_url,
-                timeout=timeout,
-                use_ntlm_proxy=True,
-            )
-        else:
-            if offline:
-                if code != 400:
-                    queue = Queue()
-                    queue.push(data, json.dumps(stats), plugin)
-                    if code == 401:
-                        log.error({
-                            'response_code': code,
-                            'response_content': content,
-                        })
-                        session_cache.delete()
-                        return AUTH_ERROR
-                    elif log.isEnabledFor(logging.DEBUG):
-                        log.warn({
-                            'response_code': code,
-                            'response_content': content,
-                        })
-                else:
-                    log.error({
-                        'response_code': code,
-                        'response_content': content,
-                    })
-            else:
-                log.error({
-                    'response_code': code,
-                    'response_content': content,
-                })
-    session_cache.delete()
-    return API_ERROR
-
-
-def sync_offline_heartbeats(args, hostname):
-    """Sends all heartbeats which were cached in the offline Queue."""
-
-    queue = Queue()
-    while True:
-        heartbeat = queue.pop()
-        if heartbeat is None:
-            break
-        status = send_heartbeat(
-            project=heartbeat['project'],
-            entity=heartbeat['entity'],
-            timestamp=heartbeat['time'],
-            branch=heartbeat['branch'],
-            hostname=hostname,
-            stats=json.loads(heartbeat['stats']),
-            key=args.key,
-            is_write=heartbeat['is_write'],
-            plugin=heartbeat['plugin'],
-            offline=args.offline,
-            hidefilenames=args.hidefilenames,
-            entity_type=heartbeat['type'],
-            proxy=args.proxy,
-            api_url=args.api_url,
-            timeout=args.timeout,
-        )
-        if status != SUCCESS:
-            if status == AUTH_ERROR:
-                return AUTH_ERROR
-            break
-    return SUCCESS
-
-
-def process_heartbeat(args, configs, hostname, heartbeat):
-    exclude = should_exclude(heartbeat['entity'], args.include, args.exclude)
-    if exclude is not False:
-        log.debug(u('Skipping because matches exclude pattern: {pattern}').format(
-            pattern=u(exclude),
-        ))
-        return SUCCESS
-
-    if heartbeat.get('entity_type') not in ['file', 'domain', 'app']:
-        heartbeat['entity_type'] = 'file'
-
-    if heartbeat['entity_type'] == 'file':
-        heartbeat['entity'] = format_file_path(heartbeat['entity'])
-
-    if heartbeat['entity_type'] != 'file' or os.path.isfile(heartbeat['entity']):
-
-        stats = get_file_stats(heartbeat['entity'],
-                               entity_type=heartbeat['entity_type'],
-                               lineno=heartbeat.get('lineno'),
-                               cursorpos=heartbeat.get('cursorpos'),
-                               plugin=args.plugin,
-                               language=heartbeat.get('language'))
-
-        project = heartbeat.get('project') or heartbeat.get('alternate_project')
-        branch = None
-        if heartbeat['entity_type'] == 'file':
-            project, branch = get_project_info(configs, heartbeat)
-
-        heartbeat['project'] = project
-        heartbeat['branch'] = branch
-        heartbeat['stats'] = stats
-        heartbeat['hostname'] = hostname
-        heartbeat['timeout'] = args.timeout
-        heartbeat['key'] = args.key
-        heartbeat['plugin'] = args.plugin
-        heartbeat['offline'] = args.offline
-        heartbeat['hidefilenames'] = args.hidefilenames
-        heartbeat['proxy'] = args.proxy
-        heartbeat['nosslverify'] = args.nosslverify
-        heartbeat['api_url'] = args.api_url
-
-        return send_heartbeat(**heartbeat)
-
-    else:
-        log.debug('File does not exist; ignoring this heartbeat.')
-        return SUCCESS
 
 
 def execute(argv=None):
     if argv:
         sys.argv = ['wakatime'] + argv
 
-    args, configs = parseArguments()
+    args, configs = parse_arguments()
 
     setup_logging(args, __version__)
 
     try:
+        heartbeats = []
 
-        hostname = args.hostname or socket.gethostname()
-
-        heartbeat = vars(args)
-        retval = process_heartbeat(args, configs, hostname, heartbeat)
+        hb = Heartbeat(vars(args), args, configs)
+        if hb:
+            heartbeats.append(hb)
+        else:
+            log.debug(hb.skip)
 
         if args.extra_heartbeats:
             try:
-                for heartbeat in json.loads(sys.stdin.readline()):
-                    retval = process_heartbeat(args, configs, hostname, heartbeat)
-            except json.JSONDecodeError:
-                retval = MALFORMED_HEARTBEAT_ERROR
+                for extra_data in json.loads(sys.stdin.readline()):
+                    hb = Heartbeat(extra_data, args, configs)
+                    if hb:
+                        heartbeats.append(hb)
+                    else:
+                        log.debug(hb.skip)
+            except json.JSONDecodeError as ex:
+                log.warning(u('Malformed extra heartbeats json: {msg}').format(
+                    msg=u(ex),
+                ))
 
+        retval = send_heartbeats(heartbeats, args, configs)
         if retval == SUCCESS:
-            retval = sync_offline_heartbeats(args, hostname)
+            queue = Queue(args, configs)
+            offline_heartbeats = queue.pop_many()
+            if len(offline_heartbeats) > 0:
+                retval = send_heartbeats(offline_heartbeats, args, configs)
 
         return retval
 
diff --git a/wakatime/offlinequeue.py b/wakatime/offlinequeue.py
index a7826ba..8d5656d 100644
--- a/wakatime/offlinequeue.py
+++ b/wakatime/offlinequeue.py
@@ -14,77 +14,68 @@ import logging
 import os
 from time import sleep
 
+from .compat import json
+from .heartbeat import Heartbeat
+
+
 try:
     import sqlite3
     HAS_SQL = True
 except ImportError:  # pragma: nocover
     HAS_SQL = False
 
-from .compat import u
-
 
 log = logging.getLogger('WakaTime')
 
 
 class Queue(object):
     db_file = '.wakatime.db'
-    table_name = 'heartbeat_1'
+    table_name = 'heartbeat_2'
 
-    def get_db_file(self):
-        home = '~'
-        if os.environ.get('WAKATIME_HOME'):
-            home = os.environ.get('WAKATIME_HOME')
-        return os.path.join(os.path.expanduser(home), '.wakatime.db')
+    args = None
+    configs = None
+
+    def __init__(self, args, configs):
+        self.args = args
+        self.configs = configs
 
     def connect(self):
-        conn = sqlite3.connect(self.get_db_file(), isolation_level=None)
+        conn = sqlite3.connect(self._get_db_file(), isolation_level=None)
         c = conn.cursor()
         c.execute('''CREATE TABLE IF NOT EXISTS {0} (
-            entity text,
-            type text,
-            time real,
-            project text,
-            branch text,
-            is_write integer,
-            stats text,
-            misc text,
-            plugin text)
+            id text,
+            heartbeat text)
         '''.format(self.table_name))
         return (conn, c)
 
-    def push(self, data, stats, plugin, misc=None):
-        if not HAS_SQL:  # pragma: nocover
+    def push(self, heartbeat):
+        if not HAS_SQL:
             return
         try:
             conn, c = self.connect()
-            heartbeat = {
-                'entity': u(data.get('entity')),
-                'type': u(data.get('type')),
-                'time': data.get('time'),
-                'project': u(data.get('project')),
-                'branch': u(data.get('branch')),
-                'is_write': 1 if data.get('is_write') else 0,
-                'stats': u(stats),
-                'misc': u(misc),
-                'plugin': u(plugin),
+            data = {
+                'id': heartbeat.get_id(),
+                'heartbeat': heartbeat.json(),
             }
-            c.execute('INSERT INTO {0} VALUES (:entity,:type,:time,:project,:branch,:is_write,:stats,:misc,:plugin)'.format(self.table_name), heartbeat)
+            c.execute('INSERT INTO {0} VALUES (:id,:heartbeat)'.format(self.table_name), data)
             conn.commit()
             conn.close()
         except sqlite3.Error:
             log.traceback()
 
     def pop(self):
-        if not HAS_SQL:  # pragma: nocover
+        if not HAS_SQL:
             return None
         tries = 3
         wait = 0.1
-        heartbeat = None
         try:
             conn, c = self.connect()
         except sqlite3.Error:
             log.traceback(logging.DEBUG)
             return None
+
+        heartbeat = None
+
         loop = True
         while loop and tries > -1:
             try:
@@ -92,40 +83,43 @@ class Queue(object):
                 c.execute('SELECT * FROM {0} LIMIT 1'.format(self.table_name))
                 row = c.fetchone()
                 if row is not None:
-                    values = []
-                    clauses = []
-                    index = 0
-                    for row_name in ['entity', 'type', 'time', 'project', 'branch', 'is_write']:
-                        if row[index] is not None:
-                            clauses.append('{0}=?'.format(row_name))
-                            values.append(row[index])
-                        else:  # pragma: nocover
-                            clauses.append('{0} IS NULL'.format(row_name))
-                        index += 1
-                    if len(values) > 0:
-                        c.execute('DELETE FROM {0} WHERE {1}'.format(self.table_name, ' AND '.join(clauses)), values)
-                    else:  # pragma: nocover
-                        c.execute('DELETE FROM {0} WHERE {1}'.format(self.table_name, ' AND '.join(clauses)))
+                    id = row[0]
+                    heartbeat = Heartbeat(json.loads(row[1]), self.args, self.configs, _clone=True)
+                    c.execute('DELETE FROM {0} WHERE id=?'.format(self.table_name), [id])
                 conn.commit()
-                if row is not None:
-                    heartbeat = {
-                        'entity': row[0],
-                        'type': row[1],
-                        'time': row[2],
-                        'project': row[3],
-                        'branch': row[4],
-                        'is_write': True if row[5] is 1 else False,
-                        'stats': row[6],
-                        'misc': row[7],
-                        'plugin': row[8],
-                    }
                 loop = False
-            except sqlite3.Error:  # pragma: nocover
+            except sqlite3.Error:
                 log.traceback(logging.DEBUG)
                 sleep(wait)
                 tries -= 1
         try:
             conn.close()
-        except sqlite3.Error:  # pragma: nocover
+        except sqlite3.Error:
             log.traceback(logging.DEBUG)
         return heartbeat
+
+    def push_many(self, heartbeats):
+        for heartbeat in heartbeats:
+            self.push(heartbeat)
+
+    def pop_many(self, limit=None):
+        if limit is None:
+            limit = 100
+
+        heartbeats = []
+
+        count = 0
+        while limit == 0 or count < limit:
+            heartbeat = self.pop()
+            if not heartbeat:
+                break
+            heartbeats.append(heartbeat)
+            count += 1
+
+        return heartbeats
+
+    def _get_db_file(self):
+        home = '~'
+        if os.environ.get('WAKATIME_HOME'):
+            home = os.environ.get('WAKATIME_HOME')
+        return os.path.join(os.path.expanduser(home), '.wakatime.db')
diff --git a/wakatime/project.py b/wakatime/project.py
index c03e732..4350b62 100644
--- a/wakatime/project.py
+++ b/wakatime/project.py
@@ -33,7 +33,7 @@ REV_CONTROL_PLUGINS = [
 ]
 
 
-def get_project_info(configs, heartbeat):
+def get_project_info(configs, heartbeat, data):
     """Find the current project and branch.
 
     First looks for a .wakatime-project file. Second, uses the --project arg.
@@ -43,21 +43,27 @@ def get_project_info(configs, heartbeat):
     Returns a project, branch tuple.
     """
 
-    project_name, branch_name = None, None
+    project_name, branch_name = heartbeat.project, heartbeat.branch
 
-    for plugin_cls in CONFIG_PLUGINS:
+    if heartbeat.type != 'file':
+        project_name = project_name or heartbeat.args.project or heartbeat.args.alternate_project
+        return project_name, branch_name
 
-        plugin_name = plugin_cls.__name__.lower()
-        plugin_configs = get_configs_for_plugin(plugin_name, configs)
+    if project_name is None or branch_name is None:
 
-        project = plugin_cls(heartbeat['entity'], configs=plugin_configs)
-        if project.process():
-            project_name = project_name or project.name()
-            branch_name = project.branch()
-            break
+        for plugin_cls in CONFIG_PLUGINS:
+
+            plugin_name = plugin_cls.__name__.lower()
+            plugin_configs = get_configs_for_plugin(plugin_name, configs)
+
+            project = plugin_cls(heartbeat.entity, configs=plugin_configs)
+            if project.process():
+                project_name = project_name or project.name()
+                branch_name = project.branch()
+                break
 
     if project_name is None:
-        project_name = heartbeat.get('project')
+        project_name = data.get('project') or heartbeat.args.project
 
     if project_name is None or branch_name is None:
 
@@ -66,14 +72,14 @@ def get_project_info(configs, heartbeat):
             plugin_name = plugin_cls.__name__.lower()
             plugin_configs = get_configs_for_plugin(plugin_name, configs)
 
-            project = plugin_cls(heartbeat['entity'], configs=plugin_configs)
+            project = plugin_cls(heartbeat.entity, configs=plugin_configs)
             if project.process():
                 project_name = project_name or project.name()
                 branch_name = branch_name or project.branch()
                 break
 
     if project_name is None:
-        project_name = heartbeat.get('alternate_project')
+        project_name = data.get('alternate_project') or heartbeat.args.alternate_project
 
     return project_name, branch_name
 
diff --git a/wakatime/session_cache.py b/wakatime/session_cache.py
index 80f5ea0..dbbfd9b 100644
--- a/wakatime/session_cache.py
+++ b/wakatime/session_cache.py
@@ -33,14 +33,8 @@ class SessionCache(object):
     db_file = '.wakatime.db'
     table_name = 'session'
 
-    def get_db_file(self):
-        home = '~'
-        if os.environ.get('WAKATIME_HOME'):
-            home = os.environ.get('WAKATIME_HOME')
-        return os.path.join(os.path.expanduser(home), '.wakatime.db')
-
     def connect(self):
-        conn = sqlite3.connect(self.get_db_file(), isolation_level=None)
+        conn = sqlite3.connect(self._get_db_file(), isolation_level=None)
         c = conn.cursor()
         c.execute('''CREATE TABLE IF NOT EXISTS {0} (
             value BLOB)
@@ -110,3 +104,9 @@ class SessionCache(object):
             conn.close()
         except:
             log.traceback(logging.DEBUG)
+
+    def _get_db_file(self):
+        home = '~'
+        if os.environ.get('WAKATIME_HOME'):
+            home = os.environ.get('WAKATIME_HOME')
+        return os.path.join(os.path.expanduser(home), '.wakatime.db')
diff --git a/wakatime/utils.py b/wakatime/utils.py
index 5ae657a..f85ab1f 100644
--- a/wakatime/utils.py
+++ b/wakatime/utils.py
@@ -14,6 +14,7 @@ import platform
 import logging
 import os
 import re
+import socket
 import sys
 
 from .__about__ import __version__
@@ -48,7 +49,7 @@ def should_exclude(entity, include, exclude):
     return False
 
 
-def get_user_agent(plugin):
+def get_user_agent(plugin=None):
     ver = sys.version_info
     python_version = '%d.%d.%d.%s.%d' % (ver[0], ver[1], ver[2], ver[3], ver[4])
     user_agent = u('wakatime/{ver} ({platform}) Python{py_ver}').format(
@@ -77,3 +78,7 @@ def format_file_path(filepath):
     except:  # pragma: nocover
         pass
     return filepath
+
+
+def get_hostname(args):
+    return args.hostname or socket.gethostname()