Repository: oppia/oppia

Issue:
Fractions Input is not Displayed Properly in Playthroughs
<!--
  - Thanks for taking the time to report a bug in the Oppia project.
  - Before filing a new issue, please do a quick search to check that it hasn't
  - already been filed on the [issue tracker](https://github.com/oppia/oppia/issues)._
  -->

**Describe the bug**
FractionInput answers are rendered as `[object Object]` in  playthrough step summaries.
 
**To Reproduce**
Steps to reproduce the behavior:
 1. Go to [this](https://www.oppia.org/create/MjZzEVOG47_1#/stats) exploration.
 2. Scroll down and click Issue 5.
 3. Open "View Playthrough 1".
 4. See answer's column.

**Observed behavior**
Found `[object Object]` in place of an answer.

**Expected behavior**
There should be a fraction rendered as the answer.
 
**Screenshots**
![image](https://user-images.githubusercontent.com/16653571/49937547-0b9fc080-fefd-11e8-80be-874bd636f695.png)

Fractions Input is not Displayed Properly in Playthroughs
<!--
  - Thanks for taking the time to report a bug in the Oppia project.
  - Before filing a new issue, please do a quick search to check that it hasn't
  - already been filed on the [issue tracker](https://github.com/oppia/oppia/issues)._
  -->

**Describe the bug**
FractionInput answers are rendered as `[object Object]` in  playthrough step summaries.
 
**To Reproduce**
Steps to reproduce the behavior:
 1. Go to [this](https://www.oppia.org/create/MjZzEVOG47_1#/stats) exploration.
 2. Scroll down and click Issue 5.
 3. Open "View Playthrough 1".
 4. See answer's column.

**Observed behavior**
Found `[object Object]` in place of an answer.

**Expected behavior**
There should be a fraction rendered as the answer.
 
**Screenshots**
![image](https://user-images.githubusercontent.com/16653571/49937547-0b9fc080-fefd-11e8-80be-874bd636f695.png)



Discussion:

Resolving patch:
diff --git a/core/domain/stats_jobs_one_off.py b/core/domain/stats_jobs_one_off.py
--- a/core/domain/stats_jobs_one_off.py
+++ b/core/domain/stats_jobs_one_off.py
@@ -19,8 +19,10 @@
 import ast
 import collections
 import copy
+import datetime
 
 from core import jobs
+from core.domain import config_domain
 from core.domain import exp_domain
 from core.domain import exp_services
 from core.domain import stats_domain
@@ -34,6 +36,228 @@
 ])
 
 
+PLAYTHROUGH_PROJECT_RELEASE_DATETIME = datetime.datetime(2018, 9, 1)
+
+
+class DeleteIllegalPlaythroughsOneOffJob(jobs.BaseMapReduceOneOffJobManager):
+    """A one-off job for deleting illegal playthroughs.
+
+    Playthroughs were introduced as a GSoC 2018 project. During the project,
+    some playthroughs were recorded which did not satisfy the requirements we
+    now place on them. This one-off job aims to get rid of such playthroughs to
+    keep the database healthy.
+
+    Specifically, we will remove playthroughs which were:
+      - Created before the final release of the project, because these
+        playthroughs did not use validation logic before being submitted into
+        the database.
+      - Created for an exploration which is not curated for playthroughs,
+        because playthroughs have the potential to store personally-identifiable
+        information. We want to ensure that we never record playthroughs in
+        explorations with malicious interactions (for example: TextInput ->
+        "Enter your credit card number"). We currently accomplish this by only
+        recording playthroughs in explorations admins feel confident are safe.
+
+    Ensures:
+        All Playthrough models deleted are removed as a reference from their
+            associated ExplorationIssue model.
+        ExplorationIssues models without any legal playthrough references are
+            deleted.
+        Individual ExplorationIssues without any legal playthrough references
+            are deleted.
+    """
+
+    @classmethod
+    def entity_classes_to_map_over(cls):
+        return [stats_models.ExplorationIssuesModel]
+
+    @classmethod
+    def is_illegal(cls, playthrough_model):
+        """Returns whether the given playthrough model is illegal.
+
+        Args:
+            playthrough_model: stats_models.PlaythroughModel | None.
+
+        Returns:
+            bool. Whether the playthrough model is illegal, and should thus be
+            deleted.
+        """
+        whitelisted_exploration_ids = (
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS.value)
+        return (
+            playthrough_model is None or
+            playthrough_model.exp_id not in whitelisted_exploration_ids or
+            playthrough_model.created_on < PLAYTHROUGH_PROJECT_RELEASE_DATETIME)
+
+    @classmethod
+    def delete_illegal_playthroughs(cls, playthrough_issue_backend_dict):
+        """Deletes illegal playthroughs referenced by the given playthrough
+        issue.
+
+        Args:
+            playthrough_issue_backend_dict: dict(str : *). The backend dict
+                representation of a stats_domain.ExplorationIssue object.
+
+        Returns:
+            tuple(int, int). A 2-tuple with the structure:
+                (playthroughs_deleted, playthroughs_remaining).
+        """
+        legal_playthroughs = []
+        illegal_playthroughs = []
+
+        ids = playthrough_issue_backend_dict['playthrough_ids']
+        for playthrough in stats_models.PlaythroughModel.get_multi(ids):
+            target_playthrough_list = (
+                illegal_playthroughs if cls.is_illegal(playthrough) else
+                legal_playthroughs)
+            target_playthrough_list.append(playthrough)
+
+        stats_models.PlaythroughModel.delete_multi(illegal_playthroughs)
+        playthrough_issue_backend_dict['playthrough_ids'] = [
+            playthrough.id for playthrough in legal_playthroughs]
+
+        return (len(illegal_playthroughs), len(legal_playthroughs))
+
+    @staticmethod
+    def map(playthrough_issues_model):
+        """Deletes any illegal playthroughs associated to the given issues. Must
+        be declared @staticmethod.
+
+        Args:
+            playthrough_issues_model: ExplorationIssuesModel.
+
+        Yields:
+            tuple(str, int). Returns the exploration id the given model is
+            associated to, and the number of playthroughs deleted from it.
+        """
+        total_playthroughs_deleted = 0
+
+        remaining_issues = []
+        for issue in playthrough_issues_model.unresolved_issues:
+            playthroughs_deleted, playthroughs_remaining = (
+                DeleteIllegalPlaythroughsOneOffJob.delete_illegal_playthroughs(
+                    issue))
+            total_playthroughs_deleted += playthroughs_deleted
+            if playthroughs_remaining:
+                remaining_issues.append(issue)
+
+        if remaining_issues:
+            playthrough_issues_model.unresolved_issues = remaining_issues
+            playthrough_issues_model.put()
+        else:
+            playthrough_issues_model.delete()
+
+        yield (playthrough_issues_model.exp_id, total_playthroughs_deleted)
+
+    @staticmethod
+    def reduce(key, stringified_values):
+        """Calculates total playthroughs deleted from a particular exploration.
+        Must be declared @staticmethod.
+
+        Args:
+            key: str. The id of the exploration.
+            stringified_values: list(str). Each item is a stringified count of
+                how many playthroughs were deleted by a map job.
+
+        Yields:
+            tuple(str). A 1-tuple containing a string which summarizes how many
+            playthroughs were deleted.
+        """
+        yield (
+            'exploration_id:%s has had %d illegal playthrough recordings '
+            'deleted.' % (key, sum(map(int, stringified_values))),)
+
+
+class PlaythroughAudit(jobs.BaseMapReduceOneOffJobManager):
+    """Performs a brief audit of playthrough recordings to make sure they pass
+    simple sanity checks.
+    """
+
+    @classmethod
+    def entity_classes_to_map_over(cls):
+        return [stats_models.PlaythroughModel]
+
+    @staticmethod
+    def map(playthrough_model):
+        """Builds audit data for inspection. Must be declared @staticmethod.
+
+        Args:
+            playthrough_model: PlaythroughModel.
+
+        Yields:
+            A 2-tuple of the form (playthrough_model.id, audit_data), where the
+            structure of audit_data is:
+                exp_id: str. The exploration the playthrough records.
+                created_on: str. The date the model was created in YYYY-MM-DD
+                    format.
+                validate_error: str | None. Stringified exception raised by
+                    trying to create the model as a domain object, or None if no
+                    error occurred.
+        """
+        if playthrough_model.deleted:
+            return
+
+        try:
+            playthrough = (
+                stats_services.get_playthrough_from_model(playthrough_model))
+            playthrough.validate()
+        except Exception as e:
+            validate_error = str(e)
+        else:
+            validate_error = None
+
+        audit_data = {
+            'exp_id': playthrough_model.exp_id,
+            'created_on': playthrough_model.created_on.strftime('%Y-%m-%d'),
+            'validate_error': validate_error,
+        }
+        yield (playthrough_model.id, audit_data)
+
+    @staticmethod
+    def reduce(key, stringified_values):
+        """Yields errors in playthrough models. Must be declared @staticmethod.
+
+        Args:
+            key: str. The id of the playthrough.
+            stringified_values: list(str). Each string is a stringified dict
+                with the following structure:
+                    exp_id: str. The exploration the playthrough records.
+                    created_on: str. The date the model was created in
+                        YYYY-MM-DD format.
+                    validate_error: str | None. Stringified exception raised by
+                        trying to create the model as a domain object, or None
+                        if no error occurred.
+
+        Yields:
+            tuple(str). A 1-tuple whose only element is an error message.
+        """
+        whitelisted_exp_ids_for_playthroughs = (
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS.value)
+
+        for stringified_value in stringified_values:
+            audit_data = ast.literal_eval(stringified_value)
+
+            if audit_data['validate_error'] is not None:
+                yield (
+                    'playthrough_id:%s could not be validated as a domain '
+                    'object because of the error: %s.' % (
+                        key, audit_data['validate_error']),)
+
+            if audit_data['exp_id'] not in whitelisted_exp_ids_for_playthroughs:
+                yield (
+                    'playthrough_id:%s was recorded in exploration_id:%s which '
+                    'has not been curated for recording.' % (
+                        key, audit_data['exp_id']),)
+
+            created_on_datetime = datetime.datetime.strptime(
+                audit_data['created_on'], '%Y-%m-%d')
+            if created_on_datetime < PLAYTHROUGH_PROJECT_RELEASE_DATETIME:
+                yield (
+                    'playthrough_id:%s was released on %s, which is before the '
+                    'GSoC 2018 submission deadline (2018-09-01) and should '
+                    'therefore not exist.' % (key, audit_data['created_on']),)
+
+
 class ExplorationIssuesModelCreatorOneOffJob(
         jobs.BaseMapReduceOneOffJobManager):
     """A one-off job for creating a default ExplorationIsssues model instance
diff --git a/core/jobs_registry.py b/core/jobs_registry.py
--- a/core/jobs_registry.py
+++ b/core/jobs_registry.py
@@ -62,9 +62,11 @@
     recommendations_jobs_one_off.ExplorationRecommendationsOneOffJob,
     skill_jobs_one_off.SkillMigrationOneOffJob,
     stats_jobs_one_off.ExplorationIssuesModelCreatorOneOffJob,
+    stats_jobs_one_off.PlaythroughAudit,
     stats_jobs_one_off.RecomputeStatisticsOneOffJob,
     stats_jobs_one_off.RecomputeStatisticsValidationCopyOneOffJob,
     stats_jobs_one_off.RegenerateMissingStatsModelsOneOffJob,
+    stats_jobs_one_off.DeleteIllegalPlaythroughsOneOffJob,
     stats_jobs_one_off.StatisticsAuditV1,
     stats_jobs_one_off.StatisticsAuditV2,
     stats_jobs_one_off.StatisticsAudit,


Test patch:
diff --git a/core/domain/stats_jobs_one_off_test.py b/core/domain/stats_jobs_one_off_test.py
--- a/core/domain/stats_jobs_one_off_test.py
+++ b/core/domain/stats_jobs_one_off_test.py
@@ -16,6 +16,9 @@
 
 """Tests for one off statistics jobs."""
 
+import datetime
+
+from core.domain import config_domain
 from core.domain import exp_domain
 from core.domain import exp_services
 from core.domain import stats_jobs_one_off
@@ -29,101 +32,353 @@
     [models.NAMES.exploration, models.NAMES.statistics])
 
 
-class ExplorationIssuesModelCreatorOneOffJobTests(test_utils.GenericTestBase):
-    exp_id1 = 'exp_id1'
-    exp_id2 = 'exp_id2'
+class OneOffJobTestBase(test_utils.GenericTestBase):
+    """Base class providing convenience methods for testing one off jobs."""
+
+    def count_one_off_jobs_in_queue(self):
+        """Counts one off jobs in the taskqueue."""
+        return self.count_jobs_in_taskqueue(
+            taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS)
+
+    def run_one_off_job(self):
+        """Begins the one off job and asserts it completes as expected.
+
+        Assumes the existence of a class constant ONE_OFF_JOB_CLASS, pointing
+        to the queue under test.
+
+        Returns:
+            *. The output of the one off job.
+        """
+        job_id = self.ONE_OFF_JOB_CLASS.create_new()
+        self.assertEqual(self.count_one_off_jobs_in_queue(), 0)
+        self.ONE_OFF_JOB_CLASS.enqueue(job_id)
+        self.assertEqual(self.count_one_off_jobs_in_queue(), 1)
+        self.process_and_flush_pending_tasks()
+        self.assertEqual(self.count_one_off_jobs_in_queue(), 0)
+        return self.ONE_OFF_JOB_CLASS.get_output(job_id)
+
+
+class DeleteIllegalPlaythroughsOneOffJobTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = stats_jobs_one_off.DeleteIllegalPlaythroughsOneOffJob
+
+    def setUp(self):
+        super(DeleteIllegalPlaythroughsOneOffJobTests, self).setUp()
+        self.exp = self.save_new_valid_exploration('EXP_ID', 'owner')
+
+    def create_playthrough_model(self):
+        """Helper method to create a simple playthrough and return its id.
+
+        Returns:
+            str. The ID of the newly created playthrough model.
+        """
+        return stats_models.PlaythroughModel.create(
+            self.exp.id, self.exp.version, issue_type='EarlyQuit',
+            issue_customization_args={}, actions=[])
+
+    def create_old_playthrough_model(self):
+        """Helper method to create a playthrough with a creation date before the
+        start of the GSoC 2018 project.
+
+        Returns:
+            str. The ID of the newly created playthrough model.
+        """
+        playthrough_id = self.create_playthrough_model()
+        playthrough_model = stats_models.PlaythroughModel.get(playthrough_id)
+        # Arbitrary date before GSoC 2018.
+        playthrough_model.created_on = datetime.datetime(2017, 12, 31)
+        playthrough_model.put()
+        return playthrough_id
+
+    def create_playthrough_issues_model(self, playthrough_ids_list):
+        """Helper method to create a playthrough issues model with multiple
+        unresolved issues and return its id.
+
+        Args:
+            playthrough_ids_list: list(list(str)). A list with the set of
+                playthrough ids each individual issue should reference.
+
+        Returns:
+            str. The ID of the new playthrough issue model.
+        """
+        return stats_models.ExplorationIssuesModel.create(
+            self.exp.id, self.exp.version, unresolved_issues=[
+                {
+                    'issue_type': 'EarlyQuit',
+                    'issue_customization_args': {
+                        'state_name': {'value': 'state_name'},
+                        'time_spent_in_exp_in_msecs': {'value': 200},
+                    },
+                    'playthrough_ids': playthrough_ids,
+                }
+                for playthrough_ids in playthrough_ids_list
+            ])
+
+    def test_playthroughs_remain_in_whitelisted_explorations(self):
+        self.set_config_property(
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS,
+            [self.exp.id])
+        playthrough_ids = [
+            self.create_playthrough_model(),
+            self.create_playthrough_model(),
+        ]
+        playthrough_issues_id = (
+            self.create_playthrough_issues_model([playthrough_ids]))
+
+        self.run_one_off_job()
+
+        # Getting these models should not raise.
+        for playthrough_id in playthrough_ids:
+            stats_models.PlaythroughModel.get(playthrough_id)
+        stats_models.ExplorationIssuesModel.get(playthrough_issues_id)
+
+    def test_playthroughs_removed_from_non_whitelisted_explorations(self):
+        self.set_config_property(
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS,
+            [self.exp.id + '-differentiated'])
+        playthrough_ids = [
+            self.create_playthrough_model(),
+            self.create_playthrough_model(),
+        ]
+        playthrough_issues_id = (
+            self.create_playthrough_issues_model([playthrough_ids]))
+
+        self.run_one_off_job()
+
+        for playthrough_id in playthrough_ids:
+            with self.assertRaisesRegexp(Exception, 'not found'):
+                stats_models.PlaythroughModel.get(playthrough_id)
+        with self.assertRaisesRegexp(Exception, 'not found'):
+            stats_models.ExplorationIssuesModel.get(playthrough_issues_id)
+
+    def test_old_playthroughs_removed(self):
+        self.set_config_property(
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS,
+            [self.exp.id])
+        new_playthrough_id = self.create_playthrough_model()
+        old_playthrough_id = self.create_old_playthrough_model()
+        playthrough_issues_id = self.create_playthrough_issues_model([
+            [new_playthrough_id, old_playthrough_id],
+        ])
+
+        self.run_one_off_job()
+
+        with self.assertRaisesRegexp(Exception, 'not found'):
+            stats_models.PlaythroughModel.get(old_playthrough_id)
+        # Should not raise.
+        stats_models.PlaythroughModel.get(new_playthrough_id)
+        # The list of supporting playthroughs should only have the new one.
+        playthrough_issues_model = (
+            stats_models.ExplorationIssuesModel.get(playthrough_issues_id))
+        self.assertEqual(
+            playthrough_issues_model.unresolved_issues[0]['playthrough_ids'],
+            [new_playthrough_id])
+
+    def test_entire_issue_removed_when_all_playthroughs_are_old(self):
+        self.set_config_property(
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS,
+            [self.exp.id])
+        old_playthrough_ids = [
+            self.create_old_playthrough_model(),
+            self.create_old_playthrough_model(),
+        ]
+        playthrough_issues_id = (
+            self.create_playthrough_issues_model([old_playthrough_ids]))
+
+        self.run_one_off_job()
+
+        for old_playthrough_id in old_playthrough_ids:
+            with self.assertRaisesRegexp(Exception, 'not found'):
+                stats_models.PlaythroughModel.get(old_playthrough_id)
+        with self.assertRaisesRegexp(Exception, 'not found'):
+            stats_models.ExplorationIssuesModel.get(playthrough_issues_id)
+
+    def test_issues_with_mixed_playthrough_ages(self):
+        self.set_config_property(
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS,
+            [self.exp.id])
+        old_playthrough_ids = [
+            self.create_old_playthrough_model(),
+            self.create_old_playthrough_model(),
+            self.create_old_playthrough_model(),
+        ]
+        new_playthrough_ids = [
+            self.create_playthrough_model(),
+            self.create_playthrough_model(),
+            self.create_playthrough_model(),
+        ]
+        playthrough_issues_id = self.create_playthrough_issues_model([
+            [old_playthrough_ids[0], old_playthrough_ids[1]],
+            [new_playthrough_ids[0], new_playthrough_ids[1]],
+            [old_playthrough_ids[2], new_playthrough_ids[2]],
+        ])
+
+        self.run_one_off_job()
+
+        # Assert old playthroughs have been deleted.
+        for playthrough_id in old_playthrough_ids:
+            with self.assertRaisesRegexp(Exception, 'not found'):
+                stats_models.PlaythroughModel.get(playthrough_id)
+        for playthrough_id in new_playthrough_ids:
+            # Should not raise.
+            stats_models.PlaythroughModel.get(playthrough_id)
+        # Only two issues remain, because one of them only had old playthroughs.
+        playthrough_issues_model = (
+            stats_models.ExplorationIssuesModel.get(playthrough_issues_id))
+        self.assertEqual(len(playthrough_issues_model.unresolved_issues), 2)
+        # The first element should be the issue where all playthroughs are new.
+        self.assertEqual(
+            playthrough_issues_model.unresolved_issues[0]['playthrough_ids'],
+            [new_playthrough_ids[0], new_playthrough_ids[1]])
+        # The second element should be the issue where one playthrough is old
+        # and the other is new. Only the new playthrough should remain, however.
+        self.assertEqual(
+            playthrough_issues_model.unresolved_issues[1]['playthrough_ids'],
+            [new_playthrough_ids[2]])
+
+
+class PlaythroughAuditTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = stats_jobs_one_off.PlaythroughAudit
+
+    def setUp(self):
+        super(PlaythroughAuditTests, self).setUp()
+        self.exp = self.save_new_valid_exploration('EXP_ID', 'owner')
+
+    def create_playthrough(self):
+        """Helper method to create and return a simple playthrough model."""
+        playthrough_id = stats_models.PlaythroughModel.create(
+            self.exp.id, self.exp.version, issue_type='EarlyQuit',
+            issue_customization_args={}, actions=[])
+        return stats_models.PlaythroughModel.get(playthrough_id)
+
+    def test_empty_output_for_good_playthrough(self):
+        self.set_config_property(
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS,
+            [self.exp.id])
+        self.create_playthrough()
+
+        output = self.run_one_off_job()
+
+        self.assertEqual(len(output), 0)
+
+    def test_output_for_pre_release_playthrough(self):
+        self.set_config_property(
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS,
+            [self.exp.id])
+        playthrough = self.create_playthrough()
+        # Set created_on to a date which is definitely before GSoC 2018.
+        playthrough.created_on = datetime.datetime(2017, 12, 31)
+        playthrough.put()
+
+        output = self.run_one_off_job()
+
+        self.assertEqual(len(output), 1)
+        self.assertIn('before the GSoC 2018 submission deadline', output[0])
+
+    def test_output_for_non_whitelisted_playthrough(self):
+        self.set_config_property(
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS,
+            [self.exp.id + 'differentiated'])
+        self.create_playthrough()
+
+        output = self.run_one_off_job()
+
+        self.assertEqual(len(output), 1)
+        self.assertIn('has not been curated for recording', output[0])
+
+    def test_output_for_bad_schema_playthrough(self):
+        self.set_config_property(
+            config_domain.WHITELISTED_EXPLORATION_IDS_FOR_PLAYTHROUGHS,
+            [self.exp.id])
+        playthrough = self.create_playthrough()
+        playthrough.actions.append({'bad schema key': 'bad schema value'})
+        playthrough.put()
+
+        output = self.run_one_off_job()
+
+        self.assertEqual(len(output), 1)
+        self.assertIn('could not be validated', output[0])
+
+
+class ExplorationIssuesModelCreatorOneOffJobTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = (
+        stats_jobs_one_off.ExplorationIssuesModelCreatorOneOffJob)
+    EXP_ID1 = 'EXP_ID1'
+    EXP_ID2 = 'EXP_ID2'
 
     def setUp(self):
         super(ExplorationIssuesModelCreatorOneOffJobTests, self).setUp()
-        self.exp1 = self.save_new_valid_exploration(self.exp_id1, 'owner')
+        self.exp1 = self.save_new_valid_exploration(self.EXP_ID1, 'owner')
         self.exp1.add_states(['New state'])
-        change_list = [exp_domain.ExplorationChange({
-            'cmd': 'add_state',
-            'state_name': 'New state'
-        })]
+        change_list = [
+            exp_domain.ExplorationChange(
+                {'cmd': 'add_state', 'state_name': 'New state'})]
         exp_services.update_exploration(
             feconf.SYSTEM_COMMITTER_ID, self.exp1.id, change_list, '')
         self.exp1 = exp_services.get_exploration_by_id(self.exp1.id)
-        self.exp2 = self.save_new_valid_exploration(self.exp_id2, 'owner')
+        self.exp2 = self.save_new_valid_exploration(self.EXP_ID2, 'owner')
 
     def test_default_job_execution(self):
-        job_id = (
-            stats_jobs_one_off.ExplorationIssuesModelCreatorOneOffJob.create_new()) # pylint: disable=line-too-long
-        stats_jobs_one_off.ExplorationIssuesModelCreatorOneOffJob.enqueue(
-            job_id)
-
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
+        self.run_one_off_job()
 
         # ExplorationIssuesModel will be created for versions 1 and 2.
-        exp_issues11 = stats_models.ExplorationIssuesModel.get_model(
-            self.exp_id1, self.exp1.version - 1)
-        self.assertEqual(exp_issues11.exp_id, self.exp_id1)
-        self.assertEqual(exp_issues11.exp_version, self.exp1.version - 1)
-        self.assertEqual(exp_issues11.unresolved_issues, [])
-
-        exp_issues12 = stats_models.ExplorationIssuesModel.get_model(
-            self.exp_id1, self.exp1.version)
-        self.assertEqual(exp_issues12.exp_id, self.exp_id1)
-        self.assertEqual(exp_issues12.exp_version, self.exp1.version)
-        self.assertEqual(exp_issues12.unresolved_issues, [])
+        exp_issues1_v1 = stats_models.ExplorationIssuesModel.get_model(
+            self.EXP_ID1, self.exp1.version - 1)
+        self.assertEqual(exp_issues1_v1.exp_id, self.EXP_ID1)
+        self.assertEqual(exp_issues1_v1.exp_version, self.exp1.version - 1)
+        self.assertEqual(exp_issues1_v1.unresolved_issues, [])
+
+        exp_issues1_v2 = stats_models.ExplorationIssuesModel.get_model(
+            self.EXP_ID1, self.exp1.version)
+        self.assertEqual(exp_issues1_v2.exp_id, self.EXP_ID1)
+        self.assertEqual(exp_issues1_v2.exp_version, self.exp1.version)
+        self.assertEqual(exp_issues1_v2.unresolved_issues, [])
 
         # ExplorationIssuesModel will be created only for version 1.
         exp_issues2 = stats_models.ExplorationIssuesModel.get_model(
-            self.exp_id2, self.exp2.version)
-        self.assertEqual(exp_issues2.exp_id, self.exp_id2)
+            self.EXP_ID2, self.exp2.version)
+        self.assertEqual(exp_issues2.exp_id, self.EXP_ID2)
         self.assertEqual(exp_issues2.exp_version, self.exp2.version)
         self.assertEqual(exp_issues2.unresolved_issues, [])
 
     def test_with_existing_exp_issues_instance(self):
         stats_models.ExplorationIssuesModel.create(
-            self.exp_id1, self.exp1.version,
-            [{
+            self.EXP_ID1, self.exp1.version, unresolved_issues=[{
                 'issue_type': 'EarlyQuit',
                 'issue_customization_args': {
                     'state_name': {
-                        'value': 'state_name1'
+                        'value': 'state_name1',
                     },
                     'time_spent_in_exp_in_msecs': {
-                        'value': 200
-                    }
+                        'value': 200,
+                    },
                 },
-                'playthrough_ids': ['playthrough_id1']
+                'playthrough_ids': ['playthrough_id1'],
             }]
         )
 
-        job_id = (
-            stats_jobs_one_off.ExplorationIssuesModelCreatorOneOffJob.create_new()) # pylint: disable=line-too-long
-        stats_jobs_one_off.ExplorationIssuesModelCreatorOneOffJob.enqueue(
-            job_id)
-
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
+        self.run_one_off_job()
 
         exp_issues1 = stats_models.ExplorationIssuesModel.get_model(
-            self.exp_id1, self.exp1.version)
-        self.assertEqual(exp_issues1.exp_id, self.exp_id1)
+            self.EXP_ID1, self.exp1.version)
+        self.assertEqual(exp_issues1.exp_id, self.EXP_ID1)
         self.assertEqual(exp_issues1.exp_version, self.exp1.version)
         self.assertEqual(exp_issues1.unresolved_issues, [])
 
         exp_issues2 = stats_models.ExplorationIssuesModel.get_model(
-            self.exp_id2, self.exp2.version)
-        self.assertEqual(exp_issues2.exp_id, self.exp_id2)
+            self.EXP_ID2, self.exp2.version)
+        self.assertEqual(exp_issues2.exp_id, self.EXP_ID2)
         self.assertEqual(exp_issues2.exp_version, self.exp2.version)
         self.assertEqual(exp_issues2.unresolved_issues, [])
 
 
-class RegenerateMissingStatsModelsOneOffJobTests(test_utils.GenericTestBase):
-    exp_id = 'exp_id1'
+class RegenerateMissingStatsModelsOneOffJobTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = stats_jobs_one_off.RegenerateMissingStatsModelsOneOffJob
+    EXP_ID = 'EXP_ID1'
 
     def setUp(self):
         super(RegenerateMissingStatsModelsOneOffJobTests, self).setUp()
 
-        self.exp1 = self.save_new_valid_exploration(self.exp_id, 'owner')
+        self.exp1 = self.save_new_valid_exploration(self.EXP_ID, 'owner')
 
         state_stats_dict = {
             'total_answers_count_v1': 3,
@@ -136,20 +391,19 @@ def setUp(self):
             'first_hit_count_v2': 0,
             'num_times_solution_viewed_v2': 0,
             'num_completions_v1': 3,
-            'num_completions_v2': 0
+            'num_completions_v2': 0,
         }
 
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 1, 7, 0, 5, 0, 2, 0,
-            {
-                self.exp1.init_state_name: state_stats_dict
-            })
+            self.EXP_ID, exp_version=1, num_starts_v1=7, num_starts_v2=0,
+            num_actual_starts_v1=5, num_actual_starts_v2=0,
+            num_completions_v1=2, num_completions_v2=0,
+            state_stats_mapping={self.exp1.init_state_name: state_stats_dict})
 
         self.exp1.add_states(['New state'])
-        change_list = [exp_domain.ExplorationChange({
-            'cmd': 'add_state',
-            'state_name': 'New state'
-        })]
+        change_list = [
+            exp_domain.ExplorationChange(
+                {'cmd': 'add_state', 'state_name': 'New state'})]
         # v2 version does not have ExplorationStatsModel.
         exp_services.update_exploration(
             feconf.SYSTEM_COMMITTER_ID, self.exp1.id, change_list, '')
@@ -159,19 +413,11 @@ def test_stats_models_regeneration_works(self):
         """Test that stats models are regenerated with correct v1 stats
         values.
         """
-        job_id = (
-            stats_jobs_one_off.RegenerateMissingStatsModelsOneOffJob.create_new()) # pylint: disable=line-too-long
-        stats_jobs_one_off.RegenerateMissingStatsModelsOneOffJob.enqueue(
-            job_id)
-
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
+        self.run_one_off_job()
 
         # Verify exploration version 2 has a stats model and values are correct.
-        exp_stats = stats_services.get_exploration_stats_by_id(
-            self.exp_id, 2)
+        exp_stats = (
+            stats_services.get_exploration_stats_by_id(self.EXP_ID, 2))
 
         self.assertEqual(exp_stats.num_starts_v1, 7)
         self.assertEqual(exp_stats.num_actual_starts_v1, 5)
@@ -186,30 +432,31 @@ def test_stats_models_regeneration_works(self):
         self.assertEqual(state_stats.num_completions_v1, 3)
 
 
-class RecomputeStateCompleteStatisticsTests(test_utils.GenericTestBase):
-    exp_id = 'exp_id'
-    state_b = 'b'
+class RecomputeStateCompleteStatisticsTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = stats_jobs_one_off.RecomputeStatisticsOneOffJob
+    EXP_ID = 'EXP_ID'
+    STATE_B = 'b'
 
     def setUp(self):
         super(RecomputeStateCompleteStatisticsTests, self).setUp()
-        self.exp = self.save_new_valid_exploration(self.exp_id, 'owner')
+        self.exp = self.save_new_valid_exploration(self.EXP_ID, 'owner')
 
         change_list = []
         exp_services.update_exploration(
-            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+            feconf.SYSTEM_COMMITTER_ID, self.EXP_ID, change_list, '')
         change_list = [
             exp_domain.ExplorationChange({
                 'cmd': exp_domain.CMD_RENAME_STATE,
                 'old_state_name': self.exp.init_state_name,
-                'new_state_name': self.state_b
+                'new_state_name': self.STATE_B,
             })
         ]
         exp_services.update_exploration(
-            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+            feconf.SYSTEM_COMMITTER_ID, self.EXP_ID, change_list, '')
 
         stats_models.StateCompleteEventLogEntryModel(
             id='id0',
-            exp_id=self.exp_id,
+            exp_id=self.EXP_ID,
             exp_version=1,
             state_name=self.exp.init_state_name,
             session_id='session_id_1',
@@ -217,7 +464,7 @@ def setUp(self):
             event_schema_version=2).put()
         stats_models.StateCompleteEventLogEntryModel(
             id='id1',
-            exp_id=self.exp_id,
+            exp_id=self.EXP_ID,
             exp_version=1,
             state_name=self.exp.init_state_name,
             session_id='session_id_2',
@@ -225,7 +472,7 @@ def setUp(self):
             event_schema_version=2).put()
         stats_models.StateCompleteEventLogEntryModel(
             id='id2',
-            exp_id=self.exp_id,
+            exp_id=self.EXP_ID,
             exp_version=1,
             state_name=self.exp.init_state_name,
             session_id='session_id_3',
@@ -233,7 +480,7 @@ def setUp(self):
             event_schema_version=1).put()
         stats_models.StateCompleteEventLogEntryModel(
             id='id3',
-            exp_id=self.exp_id,
+            exp_id=self.EXP_ID,
             exp_version=2,
             state_name=self.exp.init_state_name,
             session_id='session_id_4',
@@ -241,9 +488,9 @@ def setUp(self):
             event_schema_version=2).put()
         stats_models.StateCompleteEventLogEntryModel(
             id='id4',
-            exp_id=self.exp_id,
+            exp_id=self.EXP_ID,
             exp_version=3,
-            state_name=self.state_b,
+            state_name=self.STATE_B,
             session_id='session_id_4',
             time_spent_in_state_secs=1.0,
             event_schema_version=2).put()
@@ -259,123 +506,116 @@ def setUp(self):
             'first_hit_count_v2': 0,
             'num_times_solution_viewed_v2': 0,
             'num_completions_v1': 3,
-            'num_completions_v2': 9
+            'num_completions_v2': 9,
         }
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 1, 0, 0, 0, 0, 0, 0,
-            {
-                self.exp.init_state_name: state_stats_dict
-            })
+            self.EXP_ID, exp_version=1, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.exp.init_state_name: state_stats_dict})
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 2, 0, 0, 0, 0, 0, 0,
-            {
-                self.exp.init_state_name: state_stats_dict
-            })
+            self.EXP_ID, exp_version=2, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.exp.init_state_name: state_stats_dict})
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 3, 0, 0, 0, 0, 0, 0,
-            {
-                self.state_b: state_stats_dict
-            })
+            self.EXP_ID, exp_version=3, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.STATE_B: state_stats_dict})
 
     def test_standard_operation(self):
-        job_id = (
-            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
-        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
-
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
+        self.run_one_off_job()
 
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 1)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 1))
         model = stats_models.ExplorationStatsModel.get(model_id)
         state_stats = model.state_stats_mapping[self.exp.init_state_name]
         # Check the old event schema version was not counted.
         self.assertEqual(state_stats['num_completions_v2'], 2)
 
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 2)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 2))
         model = stats_models.ExplorationStatsModel.get(model_id)
         state_stats = model.state_stats_mapping[self.exp.init_state_name]
         # Check that the new version counts events for the previous
         # versions.
         self.assertEqual(state_stats['num_completions_v2'], 3)
 
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 3)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 3))
         model = stats_models.ExplorationStatsModel.get(model_id)
-        state_stats = model.state_stats_mapping[self.state_b]
+        state_stats = model.state_stats_mapping[self.STATE_B]
         # Check that the new version with a renamed state still
         # counts the events for the state in previous versions
         # with the old name.
         self.assertEqual(state_stats['num_completions_v2'], 4)
 
 
-class RecomputeAnswerSubmittedStatisticsTests(test_utils.GenericTestBase):
-    exp_id = 'exp_id'
-    exp_version = 1
-    session_id_1 = 'session_id_1'
-    session_id_2 = 'session_id_2'
+class RecomputeAnswerSubmittedStatisticsTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = stats_jobs_one_off.RecomputeStatisticsOneOffJob
+    EXP_ID = 'EXP_ID'
+    EXP_VERSION = 1
+    SESSION_ID_1 = 'session_id_1'
+    SESSION_ID_2 = 'session_id_2'
 
     def setUp(self):
         super(RecomputeAnswerSubmittedStatisticsTests, self).setUp()
-        self.exp = self.save_new_valid_exploration(self.exp_id, 'owner')
-        self.state = self.exp.init_state_name
+        self.exp = self.save_new_valid_exploration(self.EXP_ID, 'owner')
+        self.state_name = self.exp.init_state_name
 
         change_list = [
             exp_domain.ExplorationChange({
                 'cmd': exp_domain.CMD_RENAME_STATE,
-                'old_state_name': self.state,
-                'new_state_name': 'b'
+                'old_state_name': self.state_name,
+                'new_state_name': 'b',
             })
         ]
         exp_services.update_exploration(
-            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
-
+            feconf.SYSTEM_COMMITTER_ID, self.EXP_ID, change_list, '')
 
         stats_models.AnswerSubmittedEventLogEntryModel(
             id='id1',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_1,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_1,
             time_spent_in_state_secs=1.0,
             is_feedback_useful=True,
             event_schema_version=2).put()
         stats_models.AnswerSubmittedEventLogEntryModel(
             id='id2',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_1,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_1,
             time_spent_in_state_secs=1.0,
             is_feedback_useful=True,
             event_schema_version=2).put()
         stats_models.AnswerSubmittedEventLogEntryModel(
             id='id3',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_1,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_1,
             time_spent_in_state_secs=1.0,
             is_feedback_useful=False,
             event_schema_version=2).put()
         stats_models.AnswerSubmittedEventLogEntryModel(
             id='id4',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_1,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_1,
             time_spent_in_state_secs=1.0,
             is_feedback_useful=True,
             event_schema_version=1).put()
         stats_models.AnswerSubmittedEventLogEntryModel(
             id='id5',
-            exp_id=self.exp_id,
+            exp_id=self.EXP_ID,
             exp_version=2,
             state_name='b',
-            session_id=self.session_id_1,
+            session_id=self.SESSION_ID_1,
             time_spent_in_state_secs=1.0,
             is_feedback_useful=True,
             event_schema_version=2).put()
@@ -391,132 +631,125 @@ def setUp(self):
             'first_hit_count_v2': 0,
             'num_times_solution_viewed_v2': 0,
             'num_completions_v1': 3,
-            'num_completions_v2': 0
+            'num_completions_v2': 0,
         }
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, self.exp_version, 0, 0, 0, 0, 0, 0,
-            {
-                self.state: state_stats_dict
-            })
+            self.EXP_ID, self.EXP_VERSION, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.state_name: state_stats_dict})
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 2, 0, 0, 0, 0, 0, 0,
-            {
-                'b': state_stats_dict
-            })
+            self.EXP_ID, exp_version=2, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={'b': state_stats_dict})
 
     def test_standard_operation(self):
-        job_id = (
-            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
-        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
-
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
+        self.run_one_off_job()
 
         model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, self.exp_version)
+            self.EXP_ID, self.EXP_VERSION)
         model = stats_models.ExplorationStatsModel.get(model_id)
-        state_stats = model.state_stats_mapping[self.state]
+        state_stats = model.state_stats_mapping[self.state_name]
         self.assertEqual(state_stats['total_answers_count_v2'], 3)
         self.assertEqual(state_stats['useful_feedback_count_v2'], 2)
 
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 2)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 2))
         model = stats_models.ExplorationStatsModel.get(model_id)
         state_stats = model.state_stats_mapping['b']
         self.assertEqual(state_stats['total_answers_count_v2'], 4)
         self.assertEqual(state_stats['useful_feedback_count_v2'], 3)
 
 
-class RecomputeStateHitStatisticsTests(test_utils.GenericTestBase):
-    exp_id = 'exp_id'
+class RecomputeStateHitStatisticsTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = stats_jobs_one_off.RecomputeStatisticsOneOffJob
+    EXP_ID = 'EXP_ID'
     # Start on version 2 as the update from version 1 to 2 is only to
     # setup states for testing.
-    exp_version = 1
-    session_id_1 = 'session_id_1'
-    session_id_2 = 'session_id_2'
+    EXP_VERSION = 1
+    SESSION_ID_1 = 'session_id_1'
+    SESSION_ID_2 = 'session_id_2'
 
     def setUp(self):
         super(RecomputeStateHitStatisticsTests, self).setUp()
-        self.exp = self.save_new_valid_exploration(self.exp_id, 'owner')
-        self.state = self.exp.init_state_name
+        self.exp = self.save_new_valid_exploration(self.EXP_ID, 'owner')
+        self.state_name = self.exp.init_state_name
 
         change_list = []
         exp_services.update_exploration(
-            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+            feconf.SYSTEM_COMMITTER_ID, self.EXP_ID, change_list, '')
         change_list = [
             exp_domain.ExplorationChange({
                 'cmd': exp_domain.CMD_RENAME_STATE,
-                'old_state_name': self.state,
-                'new_state_name': 'b'
+                'old_state_name': self.state_name,
+                'new_state_name': 'b',
             })
         ]
         exp_services.update_exploration(
-            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+            feconf.SYSTEM_COMMITTER_ID, self.EXP_ID, change_list, '')
 
         stats_models.StateHitEventLogEntryModel(
             id='id1',
             event_type=feconf.EVENT_TYPE_STATE_HIT,
-            exploration_id=self.exp_id,
-            exploration_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_1,
+            exploration_id=self.EXP_ID,
+            exploration_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_1,
             params={},
             play_type=feconf.PLAY_TYPE_NORMAL,
             event_schema_version=2).put()
         stats_models.StateHitEventLogEntryModel(
             id='id2',
             event_type=feconf.EVENT_TYPE_STATE_HIT,
-            exploration_id=self.exp_id,
-            exploration_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_1,
+            exploration_id=self.EXP_ID,
+            exploration_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_1,
             params={},
             play_type=feconf.PLAY_TYPE_NORMAL,
             event_schema_version=2).put()
         stats_models.StateHitEventLogEntryModel(
             id='id3',
             event_type=feconf.EVENT_TYPE_STATE_HIT,
-            exploration_id=self.exp_id,
-            exploration_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_2,
+            exploration_id=self.EXP_ID,
+            exploration_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_2,
             params={},
             play_type=feconf.PLAY_TYPE_NORMAL,
             event_schema_version=2).put()
         stats_models.StateHitEventLogEntryModel(
             id='id4',
             event_type=feconf.EVENT_TYPE_STATE_HIT,
-            exploration_id=self.exp_id,
-            exploration_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_2,
+            exploration_id=self.EXP_ID,
+            exploration_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_2,
             params={},
             play_type=feconf.PLAY_TYPE_NORMAL,
             event_schema_version=1).put()
         stats_models.StateHitEventLogEntryModel(
             id='id5',
             event_type=feconf.EVENT_TYPE_STATE_HIT,
-            exploration_id=self.exp_id,
+            exploration_id=self.EXP_ID,
             exploration_version=2,
             state_name='a',
-            session_id=self.session_id_2,
+            session_id=self.SESSION_ID_2,
             params={},
             play_type=feconf.PLAY_TYPE_NORMAL,
             event_schema_version=2).put()
         stats_models.StateHitEventLogEntryModel(
             id='id6',
             event_type=feconf.EVENT_TYPE_STATE_HIT,
-            exploration_id=self.exp_id,
+            exploration_id=self.EXP_ID,
             exploration_version=3,
             state_name='b',
-            session_id=self.session_id_2,
+            session_id=self.SESSION_ID_2,
             params={},
             play_type=feconf.PLAY_TYPE_NORMAL,
             event_schema_version=2).put()
 
-
         state_stats_dict = {
             'total_answers_count_v1': 3,
             'total_answers_count_v2': 9,
@@ -528,138 +761,127 @@ def setUp(self):
             'first_hit_count_v2': 0,
             'num_times_solution_viewed_v2': 0,
             'num_completions_v1': 3,
-            'num_completions_v2': 0
+            'num_completions_v2': 0,
         }
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 1, 0, 0, 0, 0, 0, 0,
-            {
+            self.EXP_ID, exp_version=1, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={
                 'a': state_stats_dict,
-                self.state: state_stats_dict
+                self.state_name: state_stats_dict,
             })
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 2, 0, 0, 0, 0, 0, 0,
-            {
+            self.EXP_ID, exp_version=2, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={
                 'a': state_stats_dict,
-                self.state: state_stats_dict
+                self.state_name: state_stats_dict,
             })
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 3, 0, 0, 0, 0, 0, 0,
-            {
-                'a': state_stats_dict,
-                'b': state_stats_dict
-            })
+            self.EXP_ID, exp_version=3, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={'a': state_stats_dict, 'b': state_stats_dict})
 
     def test_standard_operation(self):
-        job_id = (
-            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
-        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
-
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
+        self.run_one_off_job()
 
         model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, self.exp_version)
+            self.EXP_ID, self.EXP_VERSION)
         model = stats_models.ExplorationStatsModel.get(model_id)
-        state_stats = model.state_stats_mapping[self.state]
+        state_stats = model.state_stats_mapping[self.state_name]
         self.assertEqual(state_stats['first_hit_count_v2'], 2)
         self.assertEqual(state_stats['total_hit_count_v2'], 3)
 
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 3)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 3))
         model = stats_models.ExplorationStatsModel.get(model_id)
         state_stats = model.state_stats_mapping['b']
         self.assertEqual(state_stats['first_hit_count_v2'], 3)
         self.assertEqual(state_stats['total_hit_count_v2'], 4)
 
     def test_session_hitting_multiple_states(self):
-        job_id = (
-            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
-        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+        self.run_one_off_job()
 
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
-
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 2)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 2))
         model = stats_models.ExplorationStatsModel.get(model_id)
         state_stats = model.state_stats_mapping['a']
         self.assertEqual(state_stats['first_hit_count_v2'], 1)
         self.assertEqual(state_stats['total_hit_count_v2'], 1)
 
 
-
-class RecomputeSolutionHitStatisticsTests(test_utils.GenericTestBase):
-    exp_id = 'exp_id'
-    exp_version = 1
-    session_id_1 = 'session_id_1'
-    session_id_2 = 'session_id_2'
-    session_id_3 = 'session_id_3'
+class RecomputeSolutionHitStatisticsTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = stats_jobs_one_off.RecomputeStatisticsOneOffJob
+    EXP_ID = 'EXP_ID'
+    EXP_VERSION = 1
+    SESSION_ID_1 = 'session_id_1'
+    SESSION_ID_2 = 'session_id_2'
+    SESSION_ID_3 = 'session_id_3'
 
     def setUp(self):
         super(RecomputeSolutionHitStatisticsTests, self).setUp()
-        self.exp = self.save_new_valid_exploration(self.exp_id, 'owner')
-        self.state = self.exp.init_state_name
+        self.exp = self.save_new_valid_exploration(self.EXP_ID, 'owner')
+        self.state_name = self.exp.init_state_name
 
         change_list = [
             exp_domain.ExplorationChange({
                 'cmd': exp_domain.CMD_RENAME_STATE,
-                'old_state_name': self.state,
-                'new_state_name': 'b'
+                'old_state_name': self.state_name,
+                'new_state_name': 'b',
             })
         ]
         exp_services.update_exploration(
-            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+            feconf.SYSTEM_COMMITTER_ID, self.EXP_ID, change_list, '')
 
         stats_models.SolutionHitEventLogEntryModel(
             id='id1',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_1,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_1,
             time_spent_in_state_secs=1.0,
             event_schema_version=2).put()
         stats_models.SolutionHitEventLogEntryModel(
             id='id2',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_1,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_1,
             time_spent_in_state_secs=1.0,
             event_schema_version=2).put()
         stats_models.SolutionHitEventLogEntryModel(
             id='id3',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_2,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_2,
             time_spent_in_state_secs=1.0,
             event_schema_version=2).put()
         stats_models.SolutionHitEventLogEntryModel(
             id='id4',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_3,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_3,
             time_spent_in_state_secs=1.0,
             event_schema_version=2).put()
         stats_models.SolutionHitEventLogEntryModel(
             id='id1',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
-            session_id=self.session_id_1,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.state_name,
+            session_id=self.SESSION_ID_1,
             time_spent_in_state_secs=1.0,
             event_schema_version=1).put()
         stats_models.SolutionHitEventLogEntryModel(
             id='id5',
-            exp_id=self.exp_id,
+            exp_id=self.EXP_ID,
             exp_version=2,
             state_name='b',
-            session_id=self.session_id_3,
+            session_id=self.SESSION_ID_3,
             time_spent_in_state_secs=1.0,
             event_schema_version=2).put()
 
@@ -674,84 +896,78 @@ def setUp(self):
             'first_hit_count_v2': 0,
             'num_times_solution_viewed_v2': 0,
             'num_completions_v1': 3,
-            'num_completions_v2': 0
+            'num_completions_v2': 0,
         }
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, self.exp_version, 0, 0, 0, 0, 0, 0,
-            {
-                self.state: state_stats_dict
-            })
+            self.EXP_ID, self.EXP_VERSION, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.state_name: state_stats_dict})
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 2, 0, 0, 0, 0, 0, 0,
-            {
-                'b': state_stats_dict
-            })
+            self.EXP_ID, exp_version=2, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={'b': state_stats_dict})
 
     def test_standard_operation(self):
-        job_id = (
-            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
-        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
-
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
+        self.run_one_off_job()
 
         model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, self.exp_version)
+            self.EXP_ID, self.EXP_VERSION)
         model = stats_models.ExplorationStatsModel.get(model_id)
-        state_stats = model.state_stats_mapping[self.state]
+        state_stats = model.state_stats_mapping[self.state_name]
         self.assertEqual(state_stats['num_times_solution_viewed_v2'], 3)
 
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 2)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 2))
         model = stats_models.ExplorationStatsModel.get(model_id)
         state_stats = model.state_stats_mapping['b']
         self.assertEqual(state_stats['num_times_solution_viewed_v2'], 4)
 
 
-class RecomputeActualStartStatisticsTests(test_utils.GenericTestBase):
-    exp_id = 'exp_id'
-    exp_version = 1
-    state = 'state_1'
+class RecomputeActualStartStatisticsTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = stats_jobs_one_off.RecomputeStatisticsOneOffJob
+    EXP_ID = 'EXP_ID'
+    EXP_VERSION = 1
+    STATE_NAME = 'state_1'
 
     def setUp(self):
         super(RecomputeActualStartStatisticsTests, self).setUp()
-        self.save_new_default_exploration(self.exp_id, 'owner')
+        self.save_new_default_exploration(self.EXP_ID, 'owner')
 
         change_list = []
         # Update exploration to version 3.
         exp_services.update_exploration(
-            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+            feconf.SYSTEM_COMMITTER_ID, self.EXP_ID, change_list, '')
         exp_services.update_exploration(
-            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+            feconf.SYSTEM_COMMITTER_ID, self.EXP_ID, change_list, '')
 
         stats_models.ExplorationActualStartEventLogEntryModel(
             id='id1',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.STATE_NAME,
             session_id='session_id_1',
             event_schema_version=2).put()
         stats_models.ExplorationActualStartEventLogEntryModel(
             id='id2',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.STATE_NAME,
             session_id='session_id_2',
             event_schema_version=2).put()
         stats_models.ExplorationActualStartEventLogEntryModel(
             id='id3',
-            exp_id=self.exp_id,
-            exp_version=self.exp_version,
-            state_name=self.state,
+            exp_id=self.EXP_ID,
+            exp_version=self.EXP_VERSION,
+            state_name=self.STATE_NAME,
             session_id='session_id_3',
             event_schema_version=1).put()
         stats_models.ExplorationActualStartEventLogEntryModel(
             id='id4',
-            exp_id=self.exp_id,
+            exp_id=self.EXP_ID,
             exp_version=2,
-            state_name=self.state,
+            state_name=self.STATE_NAME,
             session_id='session_id_4',
             event_schema_version=2).put()
 
@@ -766,73 +982,65 @@ def setUp(self):
             'first_hit_count_v2': 0,
             'num_times_solution_viewed_v2': 0,
             'num_completions_v1': 3,
-            'num_completions_v2': 0
+            'num_completions_v2': 0,
         }
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, self.exp_version, 0, 0, 0, 0, 0, 0, {
-                self.state: state_stats_dict
-                })
+            self.EXP_ID, self.EXP_VERSION, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.STATE_NAME: state_stats_dict})
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 2, 0, 0, 0, 0, 0, 0, {
-                self.state: state_stats_dict
-                })
+            self.EXP_ID, exp_version=2, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.STATE_NAME: state_stats_dict})
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 3, 0, 0, 0, 0, 0, 0, {
-                self.state: state_stats_dict
-                })
+            self.EXP_ID, exp_version=3, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.STATE_NAME: state_stats_dict})
 
     def test_standard_operation(self):
-        job_id = (stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
-        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
-
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
+        self.run_one_off_job()
 
         model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, self.exp_version)
+            self.EXP_ID, self.EXP_VERSION)
         model = stats_models.ExplorationStatsModel.get(model_id)
         self.assertEqual(model.num_actual_starts_v2, 2)
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 2)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 2))
         model = stats_models.ExplorationStatsModel.get(model_id)
         self.assertEqual(model.num_actual_starts_v2, 3)
 
     def test_recompute_for_version_with_no_events(self):
-        job_id = (stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
-        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
+        self.run_one_off_job()
 
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
-
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 3)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 3))
         model = stats_models.ExplorationStatsModel.get(model_id)
         self.assertEqual(model.num_actual_starts_v2, 3)
 
 
-class RecomputeCompleteEventStatisticsTests(test_utils.GenericTestBase):
-    exp_id = 'exp_id'
-    exp_version = 1
-    state = 'state_1'
+class RecomputeCompleteEventStatisticsTests(OneOffJobTestBase):
+    ONE_OFF_JOB_CLASS = stats_jobs_one_off.RecomputeStatisticsOneOffJob
+    EXP_ID = 'EXP_ID'
+    EXP_VERSION = 1
+    STATE_NAME = 'state_1'
 
     def setUp(self):
         super(RecomputeCompleteEventStatisticsTests, self).setUp()
-        self.save_new_default_exploration(self.exp_id, 'owner')
+        self.save_new_default_exploration(self.EXP_ID, 'owner')
 
         change_list = []
         exp_services.update_exploration(
-            feconf.SYSTEM_COMMITTER_ID, self.exp_id, change_list, '')
+            feconf.SYSTEM_COMMITTER_ID, self.EXP_ID, change_list, '')
 
         stats_models.CompleteExplorationEventLogEntryModel(
             id='id1',
             event_type=feconf.EVENT_TYPE_COMPLETE_EXPLORATION,
-            exploration_id=self.exp_id,
-            exploration_version=self.exp_version,
-            state_name=self.state,
+            exploration_id=self.EXP_ID,
+            exploration_version=self.EXP_VERSION,
+            state_name=self.STATE_NAME,
             session_id='session_id_1',
             client_time_spent_in_secs=1.0,
             params={},
@@ -841,9 +1049,9 @@ def setUp(self):
         stats_models.CompleteExplorationEventLogEntryModel(
             id='id2',
             event_type=feconf.EVENT_TYPE_COMPLETE_EXPLORATION,
-            exploration_id=self.exp_id,
-            exploration_version=self.exp_version,
-            state_name=self.state,
+            exploration_id=self.EXP_ID,
+            exploration_version=self.EXP_VERSION,
+            state_name=self.STATE_NAME,
             session_id='session_id_2',
             client_time_spent_in_secs=1.0,
             params={},
@@ -852,9 +1060,9 @@ def setUp(self):
         stats_models.CompleteExplorationEventLogEntryModel(
             id='id3',
             event_type=feconf.EVENT_TYPE_COMPLETE_EXPLORATION,
-            exploration_id=self.exp_id,
+            exploration_id=self.EXP_ID,
             exploration_version=2,
-            state_name=self.state,
+            state_name=self.STATE_NAME,
             session_id='session_id_3',
             client_time_spent_in_secs=1.0,
             params={},
@@ -872,33 +1080,28 @@ def setUp(self):
             'first_hit_count_v2': 0,
             'num_times_solution_viewed_v2': 0,
             'num_completions_v1': 3,
-            'num_completions_v2': 0
+            'num_completions_v2': 0,
         }
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, self.exp_version, 0, 0, 0, 0, 0, 0, {
-                self.state: state_stats_dict
-                })
+            self.EXP_ID, self.EXP_VERSION, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.STATE_NAME: state_stats_dict})
         stats_models.ExplorationStatsModel.create(
-            self.exp_id, 2, 0, 0, 0, 0, 0, 0, {
-                self.state: state_stats_dict
-                })
+            self.EXP_ID, exp_version=2, num_starts_v1=0, num_starts_v2=0,
+            num_actual_starts_v1=0, num_actual_starts_v2=0,
+            num_completions_v1=0, num_completions_v2=0,
+            state_stats_mapping={self.STATE_NAME: state_stats_dict})
 
     def test_standard_operation(self):
-        job_id = (
-            stats_jobs_one_off.RecomputeStatisticsOneOffJob.create_new())
-        stats_jobs_one_off.RecomputeStatisticsOneOffJob.enqueue(job_id)
-
-        self.assertEqual(
-            self.count_jobs_in_taskqueue(
-                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
-        self.process_and_flush_pending_tasks()
+        self.run_one_off_job()
 
         model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, self.exp_version)
+            self.EXP_ID, self.EXP_VERSION)
         model = stats_models.ExplorationStatsModel.get(model_id)
         self.assertEqual(model.num_completions_v2, 2)
 
-        model_id = stats_models.ExplorationStatsModel.get_entity_id(
-            self.exp_id, 2)
+        model_id = (
+            stats_models.ExplorationStatsModel.get_entity_id(self.EXP_ID, 2))
         model = stats_models.ExplorationStatsModel.get(model_id)
         self.assertEqual(model.num_completions_v2, 3)