Украинское сообщество программистов

Немного о разрыве зависимостей и TDD

Антон Мартыненко
Опубликовано 18.01.2010 в Разработка

TDD (Test-Driven Development) – это техника программирования, при которой разработка ведется через тестирование. Тесты пишутся до кода, либо до внесения изменений в существующий код. Эта техника предполагает написания множества юнит-тестов, которые тестируют код. Как правило, тесты выполняются во время интеграционного тестирования, что позволяет обнаружить ошибки.

По мере роста размера проекта тесты принимают все более важное значение, т.к. в большом проекте возникает проблемы с изменением кода. В большом проекте очень сложно менять код, не внося при этом ошибок. Чем он больше – тем сложнее понять, на что повлияет изменение, и тем выше шанс что-то сломать. При росте размера проекта важность тестов растет экспоненциально. Юнит-тесты – отличный помощник в такой ситуации.

Michael Feathers в его книге Working Effectively with Legacy Code вводит понятие «Унаследованный код» (Legacy code). Унаследованный код – это код без тестов, изменение которого может быть сложным из-за отсутствия автоматических регрессионных тестов. Объективно, это существенная часть кода в тех компаниях, где мы работаем (см. опрос – более 70% имеет ограниченное покрытие тестами, либо не пишут тесты совсем). Не секрет, что многие проекты в начале представляют собой простые и понятные системы, над которыми работают 1-2 программиста. В таких проектах написание тестов часто считается тратой времени. Но по мере роста проекта и возрастания сложности все более ощущается отсутствие автоматического регрессионного тестирования. Дизайн все более усложняется, и становится все труднее поддерживать и развивать проект. Так появляется унаследованный код…

Причины, по которым люди не пишут тесты достаточно разные. Вот немногие из них: отсутствие времени, лень, сложность написания тестов, непрофессионализм и т.д. Я бы хотел заострить внимание на сложности написания тестов, и показать пару техник, которые могут помочь. Основная причина сложности написания тестов – проблемы в архитектуре и проектировании классов. Очень часто классы имеют слишком много зависимостей, и из-за этого класс или метод невозможно изолировать для тестирования. Простой пример – форма, которая обращается к базе данных за какой-либо информацией. Обсуждение архитектуры и дизайна уходят за рамки этой статьи, но я бы хотел сказать, что такие изменения не делаются быстро, а уговорить заказчика на рефакторинг очень сложно – ведь заказчику сложно понять, почему он будет оплачивать месяцы работы, и после это не получит никакого нового функционала. Программист остается один на один с унаследованным кодом, и ему надо как-то разрывать зависимости и писать тесты.

Я бы хотел показать некоторые методики разрыва зависимостей, которые я использовал в работе.

Их автор – Michael Feathers. В книге Working Effectively with Legacy Code он описал больше методик, чем я здесь привожу. Я считаю, что описанные здесь методики наиболее востребованные и эффективные:

  • Parameterize constructor
  • Parameterize method
  • Introduce static setter
  • Extract and override call
  • Extract and override factory method

Все методики я привожу на языке C#, так как я на нем пишу. Надеюсь, он будет всем понятен =)

1. Parameterize constructor

Эта методика предназначена для выноса зависимости при помощи конструктора. Основная ее идея – создание нового конструктора, который принимает в качестве параметра интерфейс класса, от которого зависит «унаследованный код».

В унаследованном коде есть метод, который необходимо протестировать, но в этом методе содержится обращение к веб-сервису, который реализован классом WebServiceFacade в виде паттерна синглтон:

namespace ParameterizeConstructor.LegacyCode
{
    public class ClassWithDependency
    {
        public ClassWithDependency()
        {
            //some initialization
        }

        public bool DoSomething()
        {
            //...
            int caseCount = WebServiceFacade.Instance.GetCaseCount();
            if (caseCount > 0)
            {
                //...
                return true;
            }
            //...
            return false;
        }
    }
}

using System.Diagnostics;

namespace ParameterizeConstructor.LegacyCode
{
    public class WebServiceFacade
    {
        private static WebServiceFacade instance;

        public static WebServiceFacade Instance
        {
            get
            {
                if (instance == null)
                {
                    instance = new WebServiceFacade();
                }
                return instance;
            }
        }

        public int GetCaseCount()
        {
            //make call to real web service
            Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
            return 42;
        }

        //...
    }
}

Нам необходимо внести изменения в код этого метода и мы должны сначала написать тест. Но мы не можем написать юнит-тест, потому что метод будет обращаться к веб-сервису во время запуска теста. Это неприемлемо. Для разрыва зависимости нам необходимо извлечь интерфейс (Extract Interface) в классе WebServiceFacade:

namespace ParameterizeConstructor.BrokenDependency
{
    public interface IWebServiceFacade
    {
        int GetCaseCount();
    }
}

using System.Diagnostics;

namespace ParameterizeConstructor.BrokenDependency
{
    public class WebServiceFacade : IWebServiceFacade
    {
        private static IWebServiceFacade instance;

        public static IWebServiceFacade Instance
        {
            get
            {
                if (instance == null)
                {
                    instance = new WebServiceFacade();
                }
                return instance;
            }
        }

        public int GetCaseCount()
        {
            //make call to real web service
            Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
            return 42;
        }
    }
}

Теперь мы добавляем новый конструктор, не забыв оставить старый:

namespace ParameterizeConstructor.BrokenDependency
{
    public class ClassWithDependency
    {
        /// <summary>
        /// We preserved original constructor
        /// </summary>
        public ClassWithDependency()
            : this(WebServiceFacade.Instance)
        {
        }

        /// <summary>
        /// This constructor has been created for testing purposes. You can inject your dependency using this constructor
        /// </summary>
        public ClassWithDependency(IWebServiceFacade webService)
        {
            this.webService = webService;
            //some initialization has gone here
        }

        private IWebServiceFacade webService;

        public bool DoSomething()
        {
            //...
            int caseCount = webService.GetCaseCount();
            if (caseCount > 0)
            {
                //...
                return true;
            }
            //...
            return false;
        }
    }
}

В старом конструкторе мы вызываем новый конструктор с параметром WebServiceFacade.Instance это сделано для того, чтобы гарантировать сохранение интерфейса класса для всех, кто это класс будет использовать.

Теперь мы можем написать тест, используя мок:

#if TEST
using NUnit.Framework;
using Rhino.Mocks;

namespace ParameterizeConstructor.BrokenDependency
{
    [TestFixture]
    public class TestClassWithDependency
    {
        [Test]
        public void DoSomething()
        {
            var webService = MockRepository.GenerateMock<IWebServiceFacade>();

            webService.Expect(x => x.GetCaseCount()).Return(20);

            var classWithDependency = new ClassWithDependency(webService);
            bool result = classWithDependency.DoSomething();
            Assert.IsTrue(result);

            //...

            webService.VerifyAllExpectations();
        }
    }
}
#endif

2. Parameterize method

Эта методика предназначена для выноса зависимости при помощи изменения сигнатуры метода. В унаследованном коде есть метод, который необходимо протестировать, но в этом методе содержится обращение к веб-сервису, который реализован классом WebServiceFacade в виде паттерна синглтон:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace ParameterizeMethod.LegacyCode
{
    public class ClassWithDependency
    {
        public bool DoSomething()
        {
            //...
            int caseCount = WebServiceFacade.Instance.GetCaseCount();
            if (caseCount > 0)
            {
                //...
                return true;
            }
            //...
            return false;
        }
    }
}

using System.Diagnostics;

namespace ParameterizeMethod.LegacyCode
{
    public class WebServiceFacade
    {
        private static WebServiceFacade instance;

        public static WebServiceFacade Instance
        {
            get
            {
                if (instance == null)
                {
                    instance = new WebServiceFacade();
                }
                return instance;
            }
        }

        public int GetCaseCount()
        {
            //make call to real web service
            Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
            return 42;
        }
    }
}

Для разрыва зависимости нам необходимо извлечь интерфейс (Extract Interface) в классе WebServiceFacade:

namespace ParameterizeConstructor.BrokenDependency
{
    public interface IWebServiceFacade
    {
        int GetCaseCount();
    }
}

using System.Diagnostics;

namespace ParameterizeConstructor.BrokenDependency
{
    public class WebServiceFacade : IWebServiceFacade
    {
        private static IWebServiceFacade instance;

        public static IWebServiceFacade Instance
        {
            get
            {
                if (instance == null)
                {
                    instance = new WebServiceFacade();
                }
                return instance;
            }
        }

        public int GetCaseCount()
        {
            //make call to real web service
            Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
            return 42;
        }
    }
}

Теперь мы добавляем новую сигнатуру метода, в старом методе делаем вызов нового с параметром WebServiceFacade.Instance это сделано для того, чтобы гарантировать сохранение интерфейса класса для всех, кто это класс будет использовать.

namespace ParameterizeMethod.BrokenDependency
{
    public class ClassWithDependency
    {
        /// <summary>
        /// We preserved original signature
        /// </summary>
        public bool DoSomething()
        {
            return DoSomething(WebServiceFacade.Instance);
        }

        /// <summary>
        /// This method has been created for testing purposes
        /// </summary>
        public bool DoSomething(IWebServiceFacade webService)
        {
            //...
            int caseCount = webService.GetCaseCount();
            if (caseCount > 0)
            {
                //...
                return true;
            }
            //...
            return false;
        }
    }
}

Теперь мы можем написать тест:

#if TEST
using NUnit.Framework;
using Rhino.Mocks;

namespace ParameterizeMethod.BrokenDependency
{
    [TestFixture]
    public class TestClassWithDependency
    {
        [Test]
        public void DoSomething()
        {
            var webService = MockRepository.GenerateMock<IWebServiceFacade>();

            webService.Expect(x => x.GetCaseCount()).Return(20);

            var classWithDependency = new ClassWithDependency();
            bool result = classWithDependency.DoSomething(webService);
            Assert.IsTrue(result);

            //...

            webService.VerifyAllExpectations();
        }
    }
}
#endif

3. Introduce static setter

Эта методика позволяет внедрять мок-классы в синглтон и другие статические сущности. В унаследованном коде есть метод, который необходимо протестировать, но в этом методе содержится обращение к веб-сервису, который реализован классом WebServiceFacade в виде паттерна синглтон:

namespace IntroduceStaticSetter.LegacyCode
{
    public class ClassWithDependency
    {
        public bool DoSomething()
        {
            //...
            int caseCount = WebServiceFacade.Instance.GetCaseCount();
            if (caseCount > 0)
            {
                //...
                return true;
            }
            //...
            return false;
        }
    }
}

using System.Diagnostics;

namespace IntroduceStaticSetter.LegacyCode
{
    public class WebServiceFacade
    {
        private static WebServiceFacade instance;

        public static WebServiceFacade Instance
        {
            get
            {
                if (instance == null)
                {
                    instance = new WebServiceFacade();
                }
                return instance;
            }
        }

        public int GetCaseCount()
        {
            //make call to real web service
            Debug.Fail("This code makes call to real web service. This is not acceptable for unit test.");
            return 42;
        }
    }
}

Для разрыва зависимости нам необходимо извлечь интерфейс (Extract Interface) в классе WebServiceFacade а также создать статический метод – setter:

namespace IntroduceStaticSetter.BrokenDependency
{
    public interface IWebServiceFacade
    {
        int GetCaseCount();
    }
}

using System.Diagnostics;

namespace IntroduceStaticSetter.BrokenDependency
{
    public class WebServiceFacade : IWebServiceFacade
    {
        private static IWebServiceFacade instance;

        public static IWebServiceFacade Instance
        {
            get
            {
                if (instance == null)
                {
                    instance = new WebServiceFacade();
                }
                return instance;
            }
        }

        /// <summary>
        /// This method has been created for testing purposes
        /// </summary>
        public static void SetInstance(IWebServiceFacade webService)
        {
            instance = webService;
        }

        public int GetCaseCount()
        {
            //make call to real web service
            Debug.Fail("This code makes call to real web service. This is not acceptable for unit test.");
            return 42;
        }
    }
}

Класс с зависимостью остается без изменений, но теперь можно смело писать юнит-тест:

#if TEST
using NUnit.Framework;
using Rhino.Mocks;

namespace IntroduceStaticSetter.BrokenDependency
{
    [TestFixture]
    public class TestClassWithDependency
    {
        [Test]
        public void DoSomething()
        {
            var webService = MockRepository.GenerateMock<IWebServiceFacade>();

            webService.Expect(x => x.GetCaseCount()).Return(20);

            WebServiceFacade.SetInstance(webService);

            var classWithDependency = new ClassWithDependency();
            bool result = classWithDependency.DoSomething();
            Assert.IsTrue(result);

            webService.VerifyAllExpectations();
        }
    }
}
#endif

4. Extract and override call

Эта методика позволяет избавиться от зависимостей, связанных с вызовом статических методов, либо вызовов какого-либо API. Предположим у нас есть следующий код:

namespace ExtractAndOverrideCall.LegacyCode
{
    public class ClassWithDependency
    {
        public bool DoSomething()
        {
            string customerName = string.Empty;
            //...
            //here we somehow retrieve and assign result to customerName
            customerName = "Anton Martynenko";
            //...

            int caseCount = CaseCountCalculator.CalculateCaseCount(customerName);
            if (caseCount > 0)
            {
                //...
                return true;
            }
            //...
            return false;
        }
    }
}

using System.Diagnostics;

namespace ExtractAndOverrideCall.LegacyCode
{
    public class CaseCountCalculator
    {
        public static int CalculateCaseCount(string customerName)
        {
            //make call to real web service
            Debug.Fail("This code makes call to real web service. This is not acceptable for unit test.");
            return 42;
        }
    }
}

Для разрыва зависимости мы добавим публичный виртуальный метод в класс с зависимостью:

namespace ExtractAndOverrideCall.BrokenDependency
{
    public class ClassWithDependency
    {
        public bool DoSomething()
        {
            string customerName = string.Empty;
            //...
            //here we somehow retrieve and assign result to customerName
            customerName = "Anton Martynenko";
            //...

            int caseCount = CalculateCaseCount(customerName);
            if (caseCount > 0)
            {
                //...
                return true;
            }
            //...
            return false;
        }

        public virtual int CalculateCaseCount(string customerName)
        {
            return CaseCountCalculator.CalculateCaseCount(customerName);
        }
    }
}

Теперь мы можем написать тест, используя мок класса с зависимостью и подменяя во времени выполнения результаты вызова метода public virtual int CalculateCaseCount(string customerName):

#if TEST
using NUnit.Framework;
using Rhino.Mocks;

namespace ExtractAndOverrideCall.BrokenDependency
{
    [TestFixture]
    public class TestClassWithDependency
    {
        [Test]
        public void DoSomething()
        {
            var classWithDependency = MockRepository.GenerateMock<ClassWithDependency>();
            classWithDependency.Expect(x => x.CalculateCaseCount(Arg<string>.Is.Equal("Anton Martynenko")))
                .Return(31);

            bool result = classWithDependency.DoSomething();
            Assert.IsTrue(result);

            classWithDependency.VerifyAllExpectations();
        }
    }
}
#endif

5. Extract and override factory method

Эта методика позволяет разорвать зависимости, связанные с создание экземпляров классов, основанных на промежуточных результатах выполнения метода. Посмотрим пример:

namespace ExtractAndOverrideFactoryMethod.LegacyCode
{
    public class ClassWithDependency
    {
        public bool DoSomething()
        {
            string customerName = string.Empty;
            //...
            //here we somehow retrieve and assign result to customerName
            customerName = "Anton Martynenko";
            //...
            var caseCountCalculator = new CaseCountCalculator(customerName);

            int caseCount = caseCountCalculator.CalculateCaseCount();
            if (caseCount > 0)
            {
                //...
                return true;
            }
            //...
            return false;
        }
    }
}

using System.Diagnostics;

namespace ExtractAndOverrideFactoryMethod.LegacyCode
{
    public class CaseCountCalculator
    {
        public CaseCountCalculator(string customerName)
        {
            //customerName is used for initialization
        }

        public int CalculateCaseCount()
        {
            //make call to real web service
            Debug.Fail("This code makes call to real web service. This is not acceptable for unit test.");
            return 42;
        }
    }
}

Экземпляр класса CaseCountCalculator создается на основе customerName, которое вычисляется в процессе выполнения метода. В этом случае мы извлечем интерфейс в классе CaseCountCalculator и используем паттерн «фабричный метод»:

namespace ExtractAndOverrideFactoryMethod.BrokenDependency
{
    public interface ICaseCountCalculator
    {
        int CalculateCaseCount();
    }
}

using System.Diagnostics;

namespace ExtractAndOverrideFactoryMethod.BrokenDependency
{
    public class CaseCountCalculator : ICaseCountCalculator
    {
        public CaseCountCalculator(string customerName)
        {
            //customerName is used for initialization
        }

        public int CalculateCaseCount()
        {
            //make call to real web service
            Debug.Fail("This code makes call to real web serviсe. This is not acceptable for unit test.");
            return 42;
        }
    }
}

namespace ExtractAndOverrideFactoryMethod.BrokenDependency
{
    public class ClassWithDependency
    {
        public bool DoSomething()
        {
            string customerName = string.Empty;
            //...
            //here we somehow retrieve and assign result to customerName
            customerName = "Anton Martynenko";
            //...
            var caseCountCalculator = CreateCaseCountCalculator(customerName);

            int caseCount = caseCountCalculator.CalculateCaseCount();
            if (caseCount > 0)
            {
                //...
                return true;
            }
            //...
            return false;
        }

        public virtual ICaseCountCalculator CreateCaseCountCalculator(string customerName)
        {
            return new CaseCountCalculator(customerName);
        }
    }
}

Теперь мы можем написать тест, используя мок класса с зависимостью и подменяя во времени выполнения результаты вызова метода public virtual ICaseCountCalculator CreateCaseCountCalculator(string customerName):

#if TEST
using NUnit.Framework;
using Rhino.Mocks;

namespace ExtractAndOverrideFactoryMethod.BrokenDependency
{
    [TestFixture]
    public class TestClassWithDependency
    {
        [Test]
        public void DoSomething()
        {
            var caseCountCalculator = MockRepository.GenerateMock<ICaseCountCalculator>();
            caseCountCalculator.Expect(x => x.CalculateCaseCount())
                .Return(31);

            var classWithDependency = MockRepository.GenerateMock<ClassWithDependency>();
            classWithDependency.Expect(x => x.CreateCaseCountCalculator(Arg<string>.Is.Equal("Anton Martynenko")))
                .Return(caseCountCalculator);

            bool result = classWithDependency.DoSomething();
            Assert.IsTrue(result);

            caseCountCalculator.VerifyAllExpectations();
            classWithDependency.VerifyAllExpectations();
        }
    }
}
#endif

Конечно, стоит отметить, что в реальной жизни все намного сложнее, чем в примерах. Но комбинируя эти методики с остальными приемами Inversion of Control можно добиться значительного увеличения покрытия тестами и улучшения дизайна классов. Всем, кто заинтересован увеличением покрытия тестами унаследованного кода я рекомендую прочитать книгу Working Effectively with Legacy Code. Она содержит множество полезных советов для работы с тем, с чем множество из нас сталкивается каждый день =)

В примерах использовались:

  • .NET 3.5
  • Rhino Mocks 3.5
  • NUnit 2.5.2.9222

Исходники тут.

Теги: , , , ,

1 звезда2 звезды3 звезды4 звезды5 звезд (15 голосов, средний: 3.8 из 5)
Загрузка ... Загрузка ...
Распределение голосов

Понравилась статья? Подпишись на обновления по RSS/E-mail

Подписаться, не оставляя комментарий

Все комментарии (6) к “Немного о разрыве зависимостей и TDD” RSS

  1. anonymous

    Еще советую почитать/посмотреть презентации Миско Хевери. http://misko.hevery.com/presentations/.

    И одной фразой все эти случаи можно резюмировать: “Мы не можем научиться писать тесты. Все что мы можем – это писать тестируемый код”. И Dependecy Injection вам в этом в помощь.

  2. Restuta

    Спасибо Вам за статью. Сразу хотелось бы попросить у автора прощения за следующую критику, я пишу это для того, чтобы помочь вам тоже стать лучше, как и Вы старались помочь нам и мне в частности при написании статьи. Всё полезно и интересно (даже непривычно читать на русском), но вот подход к написанию некоторых тестов расходится с best-practices индустрии, которые прововедуются в этой и этой книгах. Это не значит, что тесты плохие, просто хотелось бы обратить внимание.

    Если вы пишете тест с использованием мока, то в конце теста должен быть один и только одни Verify, которые проверяет ранее построенные Expectations.Если у вас есть несколько вызовов Verify – вероятно Вы тетсируете слишком много. Также в некоторых Ваших тестах это явно не моки, а стабы, а стабы как известно не могут влиять на результат выполнения теста и следовательно для них не нужно вызывать Verify.

    Я не хочу вызвать бурю негодования и мыслей в стиле “в интернете кто-то не прав”, попытаюсь этого избежать. Давайте рассмотрим всё на конкретном примере – Ваш первый тест и будем считать, что всё написанное относится больше к нему, а к остальному с уточнениями =)

            public void DoSomething()
            {
                var webService = MockRepository.GenerateMock();
                webService.Expect(x => x.GetCaseCount()).Return(20);
                var classWithDependency = new ClassWithDependency(webService);
                bool result = classWithDependency.DoSomething();
                Assert.IsTrue(result);
                webService.VerifyAllExpectations();
            }

    Что здесь происходит? Для начала определимся, с тем что мы хотим протестировать – это метод DoSomething(). Мы фактически создаём stub, который настраиваем вот так – “Если вызван GetCaseCount, верни 20″ и дальше собственно делаем утверждение, которым уже тестируем наш метод DoSomething.
    Вызывая в конце Verify мы ещё и проверяем был ли вызван GetCaseCount внутри тестируемого метода. Зачем? Таким образом мы смешиваем State и Interaction Testing, что нехорошо т.к. мы тестируем более одной вещи за тест. Я бы переписал тест вот так:

            public void DoSomething()
            {
                var webService = MockRepository.GenerateStub();
                webService.Stub(x => x.GetCaseCount()).Return(20);
                var classWithDependency = new ClassWithDependency(webService);
                Assert.That(classWithDependency.DoSomething(), Is.True);
            }

    Таким образом мы тестируем только State, нет необходимости делать ещё и Interaction testing (хотя если возникает желание, можно сделать это в отдельном тесте, где уже будет полноценный Mock)

    Помимо сказанного – в начале статьи Вы описываете TDD таким образом, чтобы понял даже неподготовленный читатель, затем ссылаетесь на опрос, что 70% разработчиков не пишут тестов вообще либо же имеют ограниченное покрытие, следовательно вы ориентировались на те ~ 30%, которые тесты всё таки пишут? Понять код ваших тестов не имея соответствующих знаний просто невозможно.

  3. Антон Мартыненко

    Согласен по поводу тестов. К сожалению, я не гуру ТДД (пока), пишу как умею =). ИМХО очень сложно писать в унаследованном коде тесты только для проверки состояния, либо для проверки взаимодействия, т.к. код изначально весьма сложный и запутанный.
    Целью статьи не было описание ТДД, я просто хотел рассказать об этих техниках рефакторинга и мне надо было какое-то вступление. Статья расчитана на те 70%, у которых проблема с тестами. Многие хотят писать тесты, но не знают с чего начать, впрочем недавно у меня была такая же ситуация.

    Понять код ваших тестов не имея соответствующих знаний просто невозможно.

    Главное понять, что такое мок (стаб) и понять, что надо сделать для того, чтобы появилась возможность их использования.

  4. hellip

    А кстати. Санёк Баглай какое-то время назад тоже писал про TDD, правда, в достаточно бессвязном стиле.
    ЯТД, что вам было бы неплохо прикрепить к посту диаграмму по образцу фаулеровской схемы Active Tease Apart Inheritance.

  5. изучающий английский

    Я знаю крутой метод рефакторинга исходного кода: сменить
    работодателя (вместе с унаследованным кодом, написанным
    неоцененным предыдущим разработчиком). Все им пользуются.

    Если код написан другим – идеальное решение.
    Если все работодатели в стране не ценят разработчиков – то проще
    сменить страну. А что, программист один, а начальников много –
    всех не перевоспитаешь.

    Естественный отбор, конечно, присутствует, но только если человек,
    знающий больше других, сам становится инструментом отбора – и
    увольняет тупых манагеров. Больше никак.

  6. Restuta

    2 изучающий английский
    Не понял Вашего комментария, при чём тут работодатели, которые не ценят разработчиков?

Оставить комментарий

Указать свой сайт могут только зарегистрированные пользователи. Регистрация или вход.

Архив

Добавить статью

Станьте автором нашего сайта!

Какие материалы подходят для публикации? — Такие.

Присылайте статьи на editors@developers.org.ua.

Подробнее.

Популярные теги

Все теги

Комментарии

Последние комментарии

интернет магазин бытовая техника магазин Laptoper