Repository: oppia/oppia

Issue:
Write a lint check to ensure that the list of files run in .travis.yml matches the contents of the core/tests directory.
An e2e test (profileFeatures.js) has been excluded from ,travis.yml (see #8261) and this was not detected for quite a while.

We should write a check that detects cases where the llist of e2e tests in .travis.yml does not match the files that exist in core/tests/protractor.


/cc @oppia/dev-workflow-team 


Discussion:
Suggested approach:
- Add a new file scripts/travis_yml_tests.py
- In that file, have a backend test that reads the relevant Travis sections (using utils.dict_from_yaml) and reads the relevant parts in protractor.conf.js (probably using regex to extract the relevant part -- basically, have an identifier for the start and the end, extract that section, and then make a capture group to extract all the different individual suites). That backend test should ensure that all three lists are in sync.

Resolving patch:
diff --git a/scripts/pre_push_hook.py b/scripts/pre_push_hook.py
--- a/scripts/pre_push_hook.py
+++ b/scripts/pre_push_hook.py
@@ -62,6 +62,7 @@
 YARN_CMD = os.path.join(
     OPPIA_PARENT_DIR, 'oppia_tools', 'yarn-v1.17.3', 'bin', 'yarn')
 FRONTEND_TEST_SCRIPT = 'run_frontend_tests'
+TRAVIS_CI_PROTRACTOR_CHECK_SCRIPT = 'check_e2e_tests_are_captured_in_ci'
 GIT_IS_DIRTY_CMD = 'git status --porcelain --untracked-files=no'
 
 
@@ -338,6 +339,23 @@ def does_diff_include_js_or_ts_files(files_to_lint):
     return False
 
 
+def does_diff_include_travis_yml_or_js_files(files_to_lint):
+    """Returns true if diff includes .travis.yml or Javascript files.
+
+    Args:
+        files_to_lint: list(str). List of files to be linted.
+
+    Returns:
+        bool. Whether the diff contains changes in travis.yml or
+        Javascript files.
+    """
+
+    for filename in files_to_lint:
+        if filename.endswith('.js') or filename.endswith('.travis.yml'):
+            return True
+    return False
+
+
 def main(args=None):
     """Main method for pre-push hook that executes the Python/JS linters on all
     files that deviate from develop.
@@ -373,12 +391,20 @@ def main(args=None):
                         'Push failed, please correct the linting issues above.')
                     sys.exit(1)
             frontend_status = 0
+            travis_ci_check_status = 0
             if does_diff_include_js_or_ts_files(files_to_lint):
                 frontend_status = start_python_script(FRONTEND_TEST_SCRIPT)
             if frontend_status != 0:
                 python_utils.PRINT(
                     'Push aborted due to failing frontend tests.')
                 sys.exit(1)
+            if does_diff_include_travis_yml_or_js_files(files_to_lint):
+                travis_ci_check_status = start_python_script(
+                    TRAVIS_CI_PROTRACTOR_CHECK_SCRIPT)
+            if travis_ci_check_status != 0:
+                python_utils.PRINT(
+                    'Push aborted due to failing e2e test configuration check.')
+                sys.exit(1)
     return
 
 
diff --git a/utils.py b/utils.py
--- a/utils.py
+++ b/utils.py
@@ -650,6 +650,21 @@ def unescape_encoded_uri_component(escaped_string):
     return python_utils.urllib_unquote(escaped_string).decode('utf-8')
 
 
+def snake_case_to_camel_case(snake_str):
+    """Converts a string in snake_case to camelCase.
+
+    Args:
+        snake_str: str. String that is in snake_case.
+
+    Returns:
+        str. Converted string that is in camelCase.
+    """
+    components = snake_str.split('_')
+    # We capitalize the first letter of each component except the first one
+    # with the 'title' method and join them together.
+    return components[0] + ''.join(x.title() for x in components[1:])
+
+
 def get_asset_dir_prefix():
     """Returns prefix for asset directory depending whether dev or prod.
     It is used as a prefix in urls for images, css and script files.


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,7 +2,7 @@ name: CI Tests
 on: [push]
 
 jobs:
-  frontend:
+  check_e2e_tests_are_captured_in_ci:
     runs-on:  ${{ matrix.os }}
     strategy:
       matrix:
@@ -15,3 +15,5 @@ jobs:
           architecture: 'x64'
       - name: Install third party
         run: python -m scripts.install_third_party_libs
+      - name: E2E Runner Test
+        run: python -m scripts.check_e2e_tests_are_captured_in_ci
diff --git a/core/tests/data/dummy_ci_tests/.dummy_travis.yml b/core/tests/data/dummy_ci_tests/.dummy_travis.yml
new file mode 100644
--- /dev/null
+++ b/core/tests/data/dummy_ci_tests/.dummy_travis.yml
@@ -0,0 +1,8 @@
+env:
+  jobs:
+    - RUN_E2E_TESTS_ONEWORD=true
+    - RUN_E2E_TESTS_TWO_WORDS=true
+
+script:
+  - if [ "RUN_E2E_TESTS_ONEWORD" == 'true' ]; then travis_retry bash scripts/run_e2e_tests.sh --suite="oneword" --prod_env; fi
+  - if [ "RUN_E2E_TESTS_TWO_WORDS" == 'true' ]; then travis_retry bash scripts/run_e2e_tests.sh --suite="twoWords" --prod_env; fi
diff --git a/core/tests/data/dummy_ci_tests/.travis_has_extra_test.yml b/core/tests/data/dummy_ci_tests/.travis_has_extra_test.yml
new file mode 100644
--- /dev/null
+++ b/core/tests/data/dummy_ci_tests/.travis_has_extra_test.yml
@@ -0,0 +1,10 @@
+env:
+  jobs:
+    - RUN_E2E_TESTS_ONEWORD=true
+    - RUN_E2E_TESTS_TWO_WORDS=true
+    - RUN_E2E_TESTS_EXTRA=true
+
+script:
+  - if [ "RUN_E2E_TESTS_ONEWORD" == 'true' ]; then travis_retry bash scripts/run_e2e_tests.sh --suite="oneword" --prod_env; fi
+  - if [ "RUN_E2E_TESTS_TWO_WORDS" == 'true' ]; then travis_retry bash scripts/run_e2e_tests.sh --suite="twoWords" --prod_env; fi
+  - if [ "RUN_E2E_TESTS_EXTRA" == 'true' ]; then travis_retry bash scripts/run_e2e_tests.sh --suite="extra" --prod_env; fi
diff --git a/core/tests/data/dummy_ci_tests/dummy_protractor.conf.js b/core/tests/data/dummy_ci_tests/dummy_protractor.conf.js
new file mode 100644
--- /dev/null
+++ b/core/tests/data/dummy_ci_tests/dummy_protractor.conf.js
@@ -0,0 +1,10 @@
+var path = require('path')
+var suites = {
+  oneword: [
+    'protractor/oneword.js'
+  ],
+
+  twoWords: [
+    'protractor_desktop/twoWords.js'
+  ]
+};
diff --git a/core/tests/data/dummy_ci_tests/protractor_has_extra_test.conf.js b/core/tests/data/dummy_ci_tests/protractor_has_extra_test.conf.js
new file mode 100644
--- /dev/null
+++ b/core/tests/data/dummy_ci_tests/protractor_has_extra_test.conf.js
@@ -0,0 +1,14 @@
+var path = require('path')
+var suites = {
+  oneword: [
+    'protractor/oneword.js'
+  ],
+
+  twoWords: [
+    'protractor_desktop/twoWords.js'
+  ],
+
+  extra: [
+    'protractor/extra.js'
+  ]
+};
diff --git a/scripts/check_e2e_tests_are_captured_in_ci.py b/scripts/check_e2e_tests_are_captured_in_ci.py
new file mode 100644
--- /dev/null
+++ b/scripts/check_e2e_tests_are_captured_in_ci.py
@@ -0,0 +1,226 @@
+# Copyright 2020 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A script to check that travis.yml file & protractor.conf.js have the
+same e2e test suites.
+"""
+
+from __future__ import absolute_import  # pylint: disable=import-only-modules
+from __future__ import unicode_literals  # pylint: disable=import-only-modules
+
+import os
+import re
+
+import python_utils
+import utils
+
+# These 4 test suites are not present in travis ci.
+# One is extra (ie. (full: [*.js])) and three other test suites are
+# are being run by CircleCI.
+TEST_SUITES_NOT_RUN_ON_TRAVIS = [
+    'full', 'classroomPageFileUploadFeatures', 'fileUploadFeatures',
+    'topicAndStoryEditorFileUploadFeatures']
+
+TRAVIS_CI_FILE_PATH = os.path.join(os.getcwd(), '.travis.yml')
+PROTRACTOR_CONF_FILE_PATH = os.path.join(
+    os.getcwd(), 'core', 'tests', 'protractor.conf.js')
+SAMPLE_TEST_SUITE_THAT_IS_KNOWN_TO_EXIST = 'explorationImprovementsTab'
+
+
+def get_e2e_suite_names_from_jobs_travis_yml_file():
+    """Extracts the test suites from env/jobs section from
+    the .travis.yml file.
+
+    Returns:
+        list(str): An alphabetically-sorted list of names of test suites
+        from the jobs section in the .travis.yml file.
+    """
+    travis_file_content = read_and_parse_travis_yml_file()
+    jobs_str = python_utils.convert_to_bytes(travis_file_content['env']['jobs'])
+    suites_from_jobs = []
+    # The following line extracts the test suite name from the jobs section
+    # that is in the form RUN_E2E_TESTS_ACCESSIBILITY=true.
+    test_regex = re.compile(r'RUN_E2E_TESTS_([A-Z_]*)=')
+    jobs = test_regex.findall(jobs_str)
+    for job in jobs:
+        suites_from_jobs.append(
+            utils.snake_case_to_camel_case(job.lower()))
+
+    return sorted(suites_from_jobs)
+
+
+def get_e2e_suite_names_from_script_travis_yml_file():
+    """Extracts the script section from the .travis.yml file.
+
+    Returns:
+        list(str): An alphabetically-sorted list of names of test suites
+        from the script section in the .travis.yml file.
+    """
+    travis_file_content = read_and_parse_travis_yml_file()
+    script_str = python_utils.convert_to_bytes(travis_file_content['script'])
+    # The following line extracts the test suites from patterns like
+    # bash scripts/run_e2e_tests.sh --suite="accessibility"
+    e2e_test_suite_regex = re.compile(
+        r'bash scripts/run_e2e_tests.sh --suite="([a-zA-Z_-]*)"')
+    suites_list = e2e_test_suite_regex.findall(script_str)
+
+    return sorted(suites_list)
+
+
+def get_e2e_suite_names_from_protractor_file():
+    """Extracts the test suites section from the protractor.conf.js file.
+
+    Returns:
+        list(str): An alphabetically-sorted list of names of test suites
+        from the protractor.conf.js file.
+    """
+    protractor_config_file_content = read_protractor_conf_file()
+    # The following line extracts suite object from protractor.conf.js.
+    suite_object_string = re.compile(
+        r'suites = {([^}]+)}').findall(protractor_config_file_content)[0]
+
+    # The following line extracts the keys/test suites from the "key: value"
+    # pair from the suites object.
+    key_regex = re.compile(r'\b([a-zA-Z_-]*):')
+    protractor_suites = key_regex.findall(suite_object_string)
+
+    return sorted(protractor_suites)
+
+
+def read_protractor_conf_file():
+    """Returns the contents of core/tests/protractor.conf.js file.
+
+    Returns:
+        str. The contents of protractor.conf.js, as a string.
+    """
+    protractor_config_file_content = python_utils.open_file(
+        PROTRACTOR_CONF_FILE_PATH, 'r').read()
+    return protractor_config_file_content
+
+
+def read_and_parse_travis_yml_file():
+    """Returns the contents of .travis.yml, as a dict.
+
+    Returns:
+        dict. Contents of the .travis.yml file parsed as a dict.
+    """
+    travis_ci_file_content = python_utils.open_file(
+        TRAVIS_CI_FILE_PATH, 'r').read()
+    travis_ci_dict = utils.dict_from_yaml(travis_ci_file_content)
+    return travis_ci_dict
+
+
+def get_e2e_test_filenames_from_protractor_dir():
+    """Extracts the names of the all test files in core/tests/protractor
+    and core/tests/protractor_desktop directory.
+
+    Returns:
+        list(str): An alphabetically-sorted list of of the all test files
+        in core/tests/protractor and core/tests/protractor_desktop directory.
+    """
+    protractor_test_suite_files = []
+    protractor_files = os.path.join(
+        os.getcwd(), 'core', 'tests', 'protractor')
+    protractor_desktop_files = os.path.join(
+        os.getcwd(), 'core', 'tests', 'protractor_desktop')
+    for file_name in os.listdir(protractor_files):
+        protractor_test_suite_files.append(file_name)
+    for file_name in os.listdir(protractor_desktop_files):
+        protractor_test_suite_files.append(file_name)
+
+    return sorted(protractor_test_suite_files)
+
+
+def get_e2e_test_filenames_from_protractor_conf_file():
+    """Extracts the filenames from the suites object of
+    protractor.conf.js file.
+
+    Returns:
+        list(str): An alphabetically-sorted list of filenames extracted
+        from the protractor.conf.js file.
+    """
+    protractor_config_file_content = read_protractor_conf_file()
+    # The following line extracts suite object from protractor.conf.js.
+    suite_object_string = re.compile(
+        r'suites = {([^}]+)}').findall(protractor_config_file_content)[0]
+    test_files_regex = re.compile(r'/([a-zA-Z]*.js)')
+    e2e_test_files = test_files_regex.findall(suite_object_string)
+    return sorted(e2e_test_files)
+
+
+def main():
+    """Test the travis ci file and protractor.conf.js to have same
+    e2e test suites.
+    """
+    python_utils.PRINT('Checking all e2e test files are captured '
+                       'in protractor.conf.js...')
+    protractor_test_suite_files = get_e2e_test_filenames_from_protractor_dir()
+    protractor_conf_test_suites = (
+        get_e2e_test_filenames_from_protractor_conf_file())
+
+    if not protractor_test_suite_files == protractor_conf_test_suites:
+        raise Exception(
+            'One or more test file from protractor or protractor_desktop '
+            'directory is missing from protractor.conf.js')
+    python_utils.PRINT('Done!')
+
+    python_utils.PRINT('Checking e2e tests are captured in .travis.yml...')
+    protractor_test_suites = get_e2e_suite_names_from_protractor_file()
+    travis_e2e_jobs = get_e2e_suite_names_from_jobs_travis_yml_file()
+    travis_e2e_scripts = get_e2e_suite_names_from_script_travis_yml_file()
+
+    for excluded_test in TEST_SUITES_NOT_RUN_ON_TRAVIS:
+        protractor_test_suites.remove(excluded_test)
+
+    if not travis_e2e_jobs:
+        raise Exception('The e2e test suites that have been extracted from '
+                        'jobs section from travis.ci are empty.')
+    if not travis_e2e_scripts:
+        raise Exception('The e2e test suites that have been extracted from '
+                        'script section from travis.ci are empty.')
+    if not protractor_test_suites:
+        raise Exception('The e2e test suites that have been extracted from '
+                        'protractor.conf.js are empty.')
+
+    if SAMPLE_TEST_SUITE_THAT_IS_KNOWN_TO_EXIST not in travis_e2e_jobs:
+        raise Exception('{} is expected to be in the e2e test suites '
+                        'extracted from the jobs section of .travis.yml '
+                        'file, but it is missing.'
+                        .format(SAMPLE_TEST_SUITE_THAT_IS_KNOWN_TO_EXIST))
+
+    if SAMPLE_TEST_SUITE_THAT_IS_KNOWN_TO_EXIST not in travis_e2e_scripts:
+        raise Exception('{} is expected to be in the e2e test suites '
+                        'extracted from the script section of .travis.yml '
+                        'file, but it is missing.'
+                        .format(SAMPLE_TEST_SUITE_THAT_IS_KNOWN_TO_EXIST))
+
+    if SAMPLE_TEST_SUITE_THAT_IS_KNOWN_TO_EXIST not in protractor_test_suites:
+        raise Exception('{} is expected to be in the e2e test suites '
+                        'extracted from the protractor.conf.js file, '
+                        'but it is missing.'
+                        .format(SAMPLE_TEST_SUITE_THAT_IS_KNOWN_TO_EXIST))
+
+    if not (protractor_test_suites == travis_e2e_jobs and
+            travis_e2e_jobs == travis_e2e_scripts):
+        raise Exception(
+            'Protractor test suites and Travis Ci test suites are not in sync.')
+
+    python_utils.PRINT('Done!')
+
+
+# The 'no coverage' pragma is used as this line is un-testable. This is because
+# it will only be called when check_e2e_tests_are_captured_in_ci.py
+# is used as a script.
+if __name__ == '__main__':  # pragma: no cover
+    main()
diff --git a/scripts/check_e2e_tests_are_captured_in_ci_test.py b/scripts/check_e2e_tests_are_captured_in_ci_test.py
new file mode 100644
--- /dev/null
+++ b/scripts/check_e2e_tests_are_captured_in_ci_test.py
@@ -0,0 +1,376 @@
+# Copyright 2020 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit tests for scripts/check_e2e_tests_are_captured_in_ci.py."""
+from __future__ import absolute_import  # pylint: disable=import-only-modules
+from __future__ import unicode_literals  # pylint: disable=import-only-modules
+
+import os
+
+from core.tests import test_utils
+import python_utils
+import utils
+
+from . import check_e2e_tests_are_captured_in_ci
+
+DUMMY_TEST_SUITES = ['oneword', 'twoWords']
+
+DUMMY_CONF_FILES = os.path.join(
+    os.getcwd(), 'core', 'tests', 'data', 'dummy_ci_tests')
+
+
+class CheckE2eTestsCapturedInCITests(test_utils.GenericTestBase):
+    """Test the methods which performs .travis.yml and
+    protractor.conf.js sync checks.
+    """
+    def test_read_travis_ci_file(self):
+        travis_ci_file = os.path.join(
+            DUMMY_CONF_FILES, '.dummy_travis.yml')
+
+        travis_ci_file_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci, 'TRAVIS_CI_FILE_PATH',
+            travis_ci_file)
+        with travis_ci_file_swap:
+            actual_travis_ci_dict = (
+                check_e2e_tests_are_captured_in_ci
+                .read_and_parse_travis_yml_file())
+            self.assertEqual(EXPECTED_TRAVIS_CI_DICT, actual_travis_ci_dict)
+
+    def test_read_protractor_file(self):
+        protractor_config_file = os.path.join(
+            DUMMY_CONF_FILES, 'dummy_protractor.conf.js')
+
+        protractor_config_file_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci, 'PROTRACTOR_CONF_FILE_PATH',
+            protractor_config_file)
+        with protractor_config_file_swap:
+            actual_protractor_config_file = (
+                check_e2e_tests_are_captured_in_ci.read_protractor_conf_file())
+        self.assertEqual(
+            EXPECTED_PROTRACTOR_CONF_FILE, actual_protractor_config_file)
+
+    def test_get_e2e_suite_names_from_jobs_travis_yml_file(self):
+        def mock_read_travis_yml_file():
+            travis_ci_file = python_utils.open_file(
+                os.path.join(
+                    DUMMY_CONF_FILES, '.dummy_travis.yml'), 'r').read()
+            travis_ci_dict = utils.dict_from_yaml(travis_ci_file)
+            return travis_ci_dict
+
+        dummy_path = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'read_and_parse_travis_yml_file', mock_read_travis_yml_file)
+        with dummy_path:
+            actual_travis_jobs = (
+                check_e2e_tests_are_captured_in_ci
+                .get_e2e_suite_names_from_jobs_travis_yml_file())
+        self.assertEqual(DUMMY_TEST_SUITES, actual_travis_jobs)
+
+    def test_get_e2e_suite_names_from_script_travis_yml_file(self):
+        def mock_read_travis_yml_file():
+            travis_ci_file = python_utils.open_file(
+                os.path.join(
+                    DUMMY_CONF_FILES, '.dummy_travis.yml'), 'r').read()
+            travis_ci_dict = utils.dict_from_yaml(travis_ci_file)
+            return travis_ci_dict
+
+        dummy_path = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'read_and_parse_travis_yml_file', mock_read_travis_yml_file)
+        with dummy_path:
+            actual_travis_script = (
+                check_e2e_tests_are_captured_in_ci
+                .get_e2e_suite_names_from_script_travis_yml_file())
+        self.assertEqual(DUMMY_TEST_SUITES, actual_travis_script)
+
+    def test_get_e2e_suite_names_from_protractor_file(self):
+        def mock_read_protractor_conf_file():
+            protractor_config_file = python_utils.open_file(
+                os.path.join(
+                    DUMMY_CONF_FILES, 'dummy_protractor.conf.js'), 'r').read()
+            return protractor_config_file
+
+        dummy_path = self.swap(
+            check_e2e_tests_are_captured_in_ci, 'read_protractor_conf_file',
+            mock_read_protractor_conf_file)
+        with dummy_path:
+            actual_protractor_suites = (
+                check_e2e_tests_are_captured_in_ci
+                .get_e2e_suite_names_from_protractor_file())
+        self.assertEqual(DUMMY_TEST_SUITES, actual_protractor_suites)
+
+    def test_main_with_invalid_test_suites(self):
+        def mock_get_e2e_suite_names_from_protractor_file():
+            return ['oneword', 'fourWord', 'invalid', 'notPresent']
+
+        def mock_get_e2e_suite_names_from_travis_ci():
+            return ['oneword', 'twoWords']
+        mock_protractor_test_suites = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_suite_names_from_protractor_file',
+            mock_get_e2e_suite_names_from_protractor_file)
+
+        mock_travis_jobs = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_suite_names_from_jobs_travis_yml_file',
+            mock_get_e2e_suite_names_from_travis_ci)
+
+        mock_travis_scripts = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_suite_names_from_script_travis_yml_file',
+            mock_get_e2e_suite_names_from_travis_ci)
+
+        mock_tests_to_remove = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'TEST_SUITES_NOT_RUN_ON_TRAVIS', ['fourWord'])
+
+        common_test_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'SAMPLE_TEST_SUITE_THAT_IS_KNOWN_TO_EXIST',
+            'oneword')
+
+        with common_test_swap, mock_tests_to_remove:
+            with mock_protractor_test_suites, mock_travis_jobs:
+                with mock_travis_scripts:
+                    with self.assertRaisesRegexp(
+                        Exception, 'Protractor test suites and Travis Ci test '
+                                   'suites are not in sync.'):
+                        check_e2e_tests_are_captured_in_ci.main()
+
+    def test_main_with_missing_test_fail(self):
+        def mock_get_e2e_suite_names():
+            return ['oneword', 'twoWords']
+
+        mock_travis_scripts = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_suite_names_from_script_travis_yml_file',
+            mock_get_e2e_suite_names)
+
+        mock_travis_jobs = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_suite_names_from_jobs_travis_yml_file',
+            mock_get_e2e_suite_names)
+
+        mock_protractor_test_suites = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_suite_names_from_protractor_file',
+            mock_get_e2e_suite_names)
+
+        mock_tests_not_on_travis = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'TEST_SUITES_NOT_RUN_ON_TRAVIS', [])
+
+        with mock_travis_scripts:
+            with self.assertRaisesRegexp(
+                Exception, 'explorationImprovementsTab is expected to be in '
+                           'the e2e test suites extracted from the script '
+                           'section of .travis.yml file, but it is missing.'):
+                check_e2e_tests_are_captured_in_ci.main()
+
+        with mock_travis_jobs:
+            with self.assertRaisesRegexp(
+                Exception, 'explorationImprovementsTab is expected to be in '
+                           'the e2e test suites extracted from the jobs '
+                           'section of .travis.yml file, but it is missing.'):
+                check_e2e_tests_are_captured_in_ci.main()
+
+        with mock_protractor_test_suites, mock_tests_not_on_travis:
+            with self.assertRaisesRegexp(
+                Exception, 'explorationImprovementsTab is expected to be in '
+                           'the e2e test suites extracted from the '
+                           'protractor.conf.js file, but it is missing.'):
+                check_e2e_tests_are_captured_in_ci.main()
+
+    def test_main_with_invalid_travis_jobs_test_suite_length(self):
+        def mock_read_travis_yml_file():
+            travis_ci_file = python_utils.open_file(
+                os.path.join(
+                    DUMMY_CONF_FILES, '.dummy_travis.yml'), 'r').read()
+            travis_ci_dict = utils.dict_from_yaml(travis_ci_file)
+            return travis_ci_dict
+        def mock_return_empty_list():
+            return []
+
+        travis_path_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'read_and_parse_travis_yml_file',
+            mock_read_travis_yml_file)
+
+        mock_tests_to_remove = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'TEST_SUITES_NOT_RUN_ON_TRAVIS', [])
+
+        mock_e2e_travis_jobs = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_suite_names_from_jobs_travis_yml_file',
+            mock_return_empty_list)
+
+        with travis_path_swap, mock_tests_to_remove:
+            with mock_e2e_travis_jobs:
+                with self.assertRaisesRegexp(
+                    Exception, 'The e2e test suites that have been '
+                               'extracted from jobs section from travis.ci'
+                               ' are empty.'):
+                    check_e2e_tests_are_captured_in_ci.main()
+
+    def test_main_with_invalid_travis_script_test_suite_length(self):
+        def mock_read_travis_yml_file():
+            travis_ci_file = python_utils.open_file(
+                os.path.join(
+                    DUMMY_CONF_FILES, '.dummy_travis.yml'), 'r').read()
+            travis_ci_dict = utils.dict_from_yaml(travis_ci_file)
+            return travis_ci_dict
+        def mock_return_empty_list():
+            return []
+
+        travis_path_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'read_and_parse_travis_yml_file',
+            mock_read_travis_yml_file)
+
+        mock_tests_to_remove = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'TEST_SUITES_NOT_RUN_ON_TRAVIS', [])
+
+        mock_e2e_travis_script = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_suite_names_from_script_travis_yml_file',
+            mock_return_empty_list)
+
+        with travis_path_swap, mock_tests_to_remove:
+            with mock_e2e_travis_script:
+                with self.assertRaisesRegexp(
+                    Exception, 'The e2e test suites that have been extracted '
+                               'from script section from travis.ci are empty.'):
+                    check_e2e_tests_are_captured_in_ci.main()
+
+    def test_main_with_invalid_protractor_test_suite_length(self):
+        def mock_read_protractor_conf_file():
+            protractor_config_file = python_utils.open_file(
+                os.path.join(
+                    DUMMY_CONF_FILES, 'dummy_protractor.conf.js'), 'r').read()
+            return protractor_config_file
+
+        def mock_return_empty_list():
+            return []
+
+        def mock_get_e2e_test_filenames_from_protractor_dir():
+            return ['oneword.js', 'twoWords.js']
+
+        protractor_test_suite_files_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_test_filenames_from_protractor_dir',
+            mock_get_e2e_test_filenames_from_protractor_dir)
+
+        protractor_path_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci, 'read_protractor_conf_file',
+            mock_read_protractor_conf_file)
+
+        mock_tests_to_remove = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'TEST_SUITES_NOT_RUN_ON_TRAVIS', [])
+
+        mock_e2e_test_suites = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_suite_names_from_protractor_file',
+            mock_return_empty_list)
+
+        with protractor_path_swap, mock_tests_to_remove:
+            with mock_e2e_test_suites, protractor_test_suite_files_swap:
+                with self.assertRaisesRegexp(
+                    Exception, 'The e2e test suites that have been extracted'
+                               ' from protractor.conf.js are empty.'):
+                    check_e2e_tests_are_captured_in_ci.main()
+
+    def test_main_with_missing_file_from_protractor_conf_file_fail(self):
+        def mock_get_e2e_test_filenames_from_protractor_dir():
+            return ['oneword.js', 'twoWords.js']
+
+        protractor_test_suite_files_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_test_filenames_from_protractor_dir',
+            mock_get_e2e_test_filenames_from_protractor_dir)
+
+        with protractor_test_suite_files_swap:
+            with self.assertRaisesRegexp(
+                Exception, 'One or more test file from protractor or '
+                           'protractor_desktop directory is missing from '
+                           'protractor.conf.js'):
+                check_e2e_tests_are_captured_in_ci.main()
+
+    def test_main_without_errors(self):
+        def mock_get_e2e_test_filenames_from_protractor_dir():
+            return ['oneword.js', 'twoWords.js']
+
+        def mock_read_protractor_conf_file():
+            protractor_config_file = python_utils.open_file(
+                os.path.join(
+                    DUMMY_CONF_FILES, 'dummy_protractor.conf.js'), 'r').read()
+            return protractor_config_file
+
+        def mock_read_travis_yml_file():
+            travis_ci_file = python_utils.open_file(
+                os.path.join(
+                    DUMMY_CONF_FILES, '.dummy_travis.yml'), 'r').read()
+            travis_ci_dict = utils.dict_from_yaml(travis_ci_file)
+            return travis_ci_dict
+
+        protractor_test_suite_files_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'get_e2e_test_filenames_from_protractor_dir',
+            mock_get_e2e_test_filenames_from_protractor_dir)
+        protractor_path_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci, 'read_protractor_conf_file',
+            mock_read_protractor_conf_file)
+        travis_path_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'read_and_parse_travis_yml_file', mock_read_travis_yml_file)
+        common_test_swap = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'SAMPLE_TEST_SUITE_THAT_IS_KNOWN_TO_EXIST',
+            'oneword')
+
+        mock_tests_to_remove = self.swap(
+            check_e2e_tests_are_captured_in_ci,
+            'TEST_SUITES_NOT_RUN_ON_TRAVIS', [])
+
+        with protractor_path_swap, travis_path_swap, mock_tests_to_remove:
+            with common_test_swap, protractor_test_suite_files_swap:
+                check_e2e_tests_are_captured_in_ci.main()
+
+
+EXPECTED_TRAVIS_CI_DICT = {
+    'env': {
+        'jobs': [
+            'RUN_E2E_TESTS_ONEWORD=true', 'RUN_E2E_TESTS_TWO_WORDS=true']
+        },
+    'script': [
+        'if [ "RUN_E2E_TESTS_ONEWORD" == \'true\' ]; '
+        'then travis_retry bash scripts/run_e2e_tests.sh'
+        ' --suite="oneword" --prod_env; fi',
+        'if [ "RUN_E2E_TESTS_TWO_WORDS" == \'true\' ]; '
+        'then travis_retry bash scripts/run_e2e_tests.sh'
+        ' --suite="twoWords" --prod_env; fi']}
+
+EXPECTED_PROTRACTOR_CONF_FILE = """var path = require('path')
+var suites = {
+  oneword: [
+    'protractor/oneword.js'
+  ],
+
+  twoWords: [
+    'protractor_desktop/twoWords.js'
+  ]
+};
+"""
diff --git a/scripts/pre_push_hook_test.py b/scripts/pre_push_hook_test.py
--- a/scripts/pre_push_hook_test.py
+++ b/scripts/pre_push_hook_test.py
@@ -67,6 +67,11 @@ def mock_start_linter(unused_files_to_lint):
         def mock_does_diff_include_js_or_ts_files(unused_files_to_lint):
             return self.does_diff_include_js_or_ts_files
 
+        self.does_diff_include_travis_yml_or_js_files = False
+        def mock_does_diff_include_travis_yml_or_js_files(
+                unused_files_to_lint):
+            return self.does_diff_include_travis_yml_or_js_files
+
         self.popen_swap = self.swap(subprocess, 'Popen', mock_popen)
         self.get_remote_name_swap = self.swap(
             pre_push_hook, 'get_remote_name', mock_get_remote_name)
@@ -84,6 +89,10 @@ def mock_does_diff_include_js_or_ts_files(unused_files_to_lint):
         self.js_or_ts_swap = self.swap(
             pre_push_hook, 'does_diff_include_js_or_ts_files',
             mock_does_diff_include_js_or_ts_files)
+        self.travis_yml_or_js_files_swap = self.swap(
+            pre_push_hook,
+            'does_diff_include_travis_yml_or_js_files',
+            mock_does_diff_include_travis_yml_or_js_files)
 
     def test_start_subprocess_for_result(self):
         with self.popen_swap:
@@ -436,6 +445,16 @@ def test_does_diff_include_js_or_ts_files_with_no_file(self):
             pre_push_hook.does_diff_include_js_or_ts_files(
                 ['file1.html', 'file2.py']))
 
+    def test_does_diff_include_travis_yml_or_js_files(self):
+        self.assertTrue(
+            pre_push_hook.does_diff_include_travis_yml_or_js_files(
+                ['file1.js', 'protractor.conf.js', '.travis.yml']))
+
+    def test_does_diff_include_travis_yml_or_js_files_fail(self):
+        self.assertFalse(
+            pre_push_hook.does_diff_include_travis_yml_or_js_files(
+                ['file1.ts', 'file2.ts', 'file3.html']))
+
     def test_repo_in_dirty_state(self):
         def mock_has_uncommitted_files():
             return True
@@ -493,6 +512,24 @@ def mock_start_python_script(unused_script):
         self.assertTrue(
             'Push aborted due to failing frontend tests.' in self.print_arr)
 
+    def test_invalid_travis_e2e_test_suites_failure(self):
+        self.does_diff_include_travis_yml_or_js_files = True
+
+        def mock_start_python_script(unused_script):
+            return 1
+        start_python_script_swap = self.swap(
+            pre_push_hook, 'start_python_script', mock_start_python_script)
+        with self.get_remote_name_swap, self.get_refs_swap, self.print_swap:
+            with self.collect_files_swap, self.uncommitted_files_swap:
+                with self.check_output_swap, self.start_linter_swap:
+                    with start_python_script_swap:
+                        with self.travis_yml_or_js_files_swap:
+                            with self.assertRaises(SystemExit):
+                                pre_push_hook.main(args=[])
+        self.assertTrue(
+            'Push aborted due to failing e2e test configuration check.'
+            in self.print_arr)
+
     def test_main_with_install_arg(self):
         check_function_calls = {
             'install_hook_is_called': False
diff --git a/utils_test.py b/utils_test.py
--- a/utils_test.py
+++ b/utils_test.py
@@ -327,3 +327,11 @@ def test_get_supported_audio_language_description_with_invalid_code(self):
             Exception, 'Unsupported audio language code: invalid_code'):
             utils.get_supported_audio_language_description(
                 invalid_language_code)
+
+    def test_snake_case_to_camel_case(self):
+        camel_case_str1 = utils.snake_case_to_camel_case('user_id_number')
+        camel_case_str2 = utils.snake_case_to_camel_case('hello_world')
+        camel_case_str3 = utils.snake_case_to_camel_case('test1')
+        self.assertEqual(camel_case_str1, 'userIdNumber')
+        self.assertEqual(camel_case_str2, 'helloWorld')
+        self.assertEqual(camel_case_str3, 'test1')